Coverage for little_loops / parallel / worker_pool.py: 10%

538 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -0500

1"""Worker pool for parallel issue processing with git worktree isolation. 

2 

3Each worker operates in an isolated git worktree, allowing concurrent issue 

4processing without file conflicts. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11import re 

12import subprocess 

13import sys 

14import threading 

15import time 

16from collections.abc import Callable 

17from concurrent.futures import Future, ThreadPoolExecutor 

18from datetime import datetime 

19from pathlib import Path 

20from typing import TYPE_CHECKING, Any, cast 

21 

22from little_loops.host_runner import resolve_host 

23from little_loops.output_parsing import parse_ready_issue_output 

24from little_loops.parallel.git_lock import GitLock 

25from little_loops.parallel.types import ParallelConfig, WorkerResult, WorkerStage 

26from little_loops.subprocess_utils import ( 

27 assemble_guillotine_prompt, 

28 detect_context_handoff, 

29 read_continuation_prompt, 

30 read_sentinel, 

31 write_sentinel, 

32) 

33from little_loops.subprocess_utils import ( 

34 run_claude_command as _run_claude_base, 

35) 

36from little_loops.work_verification import EXCLUDED_DIRECTORIES, verify_work_was_done 

37 

38if TYPE_CHECKING: 

39 from little_loops.config import BRConfig 

40 from little_loops.issue_parser import IssueInfo 

41 from little_loops.logger import Logger 

42 from little_loops.parallel.types import SprintWorkerContext 

43 

44 

45class WorkerPool: 

46 """Thread pool for processing issues in isolated git worktrees. 

47 

48 Each worker: 

49 1. Creates a dedicated git worktree and branch 

50 2. Runs issue validation and implementation via Claude CLI 

51 3. Commits changes locally 

52 4. Returns results for merge coordination 

53 

54 Example: 

55 >>> pool = WorkerPool(parallel_config, br_config, logger) 

56 >>> pool.start() 

57 >>> future = pool.submit(issue_info) 

58 >>> result = future.result() # WorkerResult 

59 >>> pool.shutdown() 

60 """ 

61 

62 def __init__( 

63 self, 

64 parallel_config: ParallelConfig, 

65 br_config: BRConfig, 

66 logger: Logger, 

67 repo_path: Path | None = None, 

68 git_lock: GitLock | None = None, 

69 ) -> None: 

70 """Initialize the worker pool. 

71 

72 Args: 

73 parallel_config: Parallel processing configuration 

74 br_config: Project configuration (for category actions) 

75 logger: Logger for worker output 

76 repo_path: Path to the git repository (default: current directory) 

77 git_lock: Shared lock for git operations (created if not provided) 

78 """ 

79 self.parallel_config = parallel_config 

80 self.br_config = br_config 

81 self.logger = logger 

82 self.repo_path = repo_path or Path.cwd() 

83 self._git_lock = git_lock or GitLock(logger) 

84 self._executor: ThreadPoolExecutor | None = None 

85 self._active_workers: dict[str, Future[WorkerResult]] = {} 

86 # Track active subprocesses for forceful termination on shutdown 

87 self._active_processes: dict[str, subprocess.Popen[str]] = {} 

88 # Track active worktree paths to prevent cleanup while in use (BUG-142) 

89 self._active_worktrees: set[Path] = set() 

90 self._process_lock = threading.Lock() 

91 # Track callbacks currently executing 

92 self._pending_callbacks: set[str] = set() 

93 self._callback_lock = threading.Lock() 

94 # Shutdown tracking for interrupted worker detection (ENH-036) 

95 self._shutdown_requested = False 

96 self._terminated_during_shutdown: set[str] = set() 

97 # Track worker processing stages for progress visibility (ENH-262) 

98 self._worker_stages: dict[str, WorkerStage] = {} 

99 

100 def start(self) -> None: 

101 """Start the worker pool.""" 

102 if self._executor is not None: 

103 return 

104 

105 # Ensure worktree base directory exists 

106 worktree_base = self.repo_path / self.parallel_config.worktree_base 

107 worktree_base.mkdir(parents=True, exist_ok=True) 

108 

109 self._executor = ThreadPoolExecutor( 

110 max_workers=self.parallel_config.max_workers, 

111 thread_name_prefix="issue-worker", 

112 ) 

113 self.logger.info(f"Worker pool started with {self.parallel_config.max_workers} workers") 

114 

115 def shutdown(self, wait: bool = True) -> None: 

116 """Shutdown the worker pool. 

117 

118 Args: 

119 wait: Whether to wait for pending tasks to complete 

120 """ 

121 if self._executor is None: 

122 return 

123 

124 self.logger.info("Shutting down worker pool...") 

125 

126 # First, terminate all active subprocesses to unblock worker threads 

127 if not wait: 

128 self.terminate_all_processes() 

129 

130 self._executor.shutdown(wait=wait) 

131 self._executor = None 

132 

133 def set_shutdown_requested(self, value: bool = True) -> None: 

134 """Set the shutdown flag. 

135 

136 Called by orchestrator during shutdown to enable tracking of 

137 workers that are terminated due to shutdown vs. actual failures. 

138 """ 

139 self._shutdown_requested = value 

140 

141 def terminate_all_processes(self) -> None: 

142 """Forcefully terminate all active subprocesses. 

143 

144 Called when we need to abort workers immediately, 

145 such as on timeout or shutdown. 

146 """ 

147 with self._process_lock: 

148 for issue_id, process in list(self._active_processes.items()): 

149 if process.poll() is None: # Still running 

150 self.logger.warning( 

151 f"Terminating subprocess for {issue_id} (PID {process.pid})" 

152 ) 

153 # Track issues terminated during shutdown for interrupted detection (ENH-036) 

154 if self._shutdown_requested: 

155 self._terminated_during_shutdown.add(issue_id) 

156 try: 

157 # Send SIGTERM first for graceful termination 

158 process.terminate() 

159 try: 

160 process.wait(timeout=5) 

161 except subprocess.TimeoutExpired: 

162 # Force kill if SIGTERM didn't work 

163 self.logger.warning(f"Force killing {issue_id} (PID {process.pid})") 

164 process.kill() 

165 process.wait(timeout=2) 

166 except Exception as e: 

167 self.logger.error(f"Failed to terminate {issue_id}: {e}") 

168 self._active_processes.clear() 

169 

170 def submit( 

171 self, 

172 issue: IssueInfo, 

173 on_complete: Callable[[WorkerResult], None] | None = None, 

174 ) -> Future[WorkerResult]: 

175 """Submit an issue for processing. 

176 

177 Args: 

178 issue: Issue to process 

179 on_complete: Optional callback when processing completes 

180 

181 Returns: 

182 Future that will contain the WorkerResult 

183 """ 

184 if self._executor is None: 

185 raise RuntimeError("Worker pool not started") 

186 

187 future = self._executor.submit(self._process_issue, issue) 

188 with self._process_lock: 

189 self._active_workers[issue.issue_id] = future 

190 

191 if on_complete: 

192 future.add_done_callback( 

193 lambda f: self._handle_completion(f, on_complete, issue.issue_id) 

194 ) 

195 

196 return future 

197 

198 def _handle_completion( 

199 self, 

200 future: Future[WorkerResult], 

201 callback: Callable[[WorkerResult], None], 

202 issue_id: str, 

203 ) -> None: 

204 """Handle worker completion and invoke callback.""" 

205 with self._callback_lock: 

206 self._pending_callbacks.add(issue_id) 

207 try: 

208 try: 

209 result = future.result() 

210 except Exception as e: 

211 self.logger.error(f"Worker future failed for {issue_id}: {e}") 

212 result = WorkerResult( 

213 issue_id=issue_id, 

214 success=False, 

215 branch_name="", 

216 worktree_path=Path(), 

217 error=f"Worker future failed: {e}", 

218 ) 

219 # Set final stage based on result (ENH-262) 

220 if result.success: 

221 self.set_worker_stage(issue_id, WorkerStage.COMPLETED) 

222 elif result.interrupted: 

223 self.set_worker_stage(issue_id, WorkerStage.INTERRUPTED) 

224 else: 

225 self.set_worker_stage(issue_id, WorkerStage.FAILED) 

226 try: 

227 callback(result) 

228 except Exception as e: 

229 self.logger.error(f"Worker completion callback failed for {issue_id}: {e}") 

230 finally: 

231 with self._callback_lock: 

232 self._pending_callbacks.discard(issue_id) 

233 

234 def _process_issue(self, issue: IssueInfo) -> WorkerResult: 

235 """Process a single issue in an isolated worktree. 

236 

237 Args: 

238 issue: Issue to process 

239 

240 Returns: 

241 WorkerResult with processing outcome 

242 """ 

243 start_time = time.time() 

244 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

245 if self.parallel_config.use_feature_branches: 

246 from little_loops.issue_parser import slugify 

247 

248 branch_name = f"feature/{issue.issue_id.lower()}-{slugify(issue.title)}" 

249 else: 

250 branch_name = f"parallel/{issue.issue_id.lower()}-{timestamp}" 

251 worktree_path = ( 

252 self.repo_path 

253 / self.parallel_config.worktree_base 

254 / f"worker-{issue.issue_id.lower()}-{timestamp}" 

255 ) 

256 

257 # Set initial stage for progress tracking (ENH-262) 

258 self.set_worker_stage(issue.issue_id, WorkerStage.SETUP) 

259 

260 # Capture baseline of main repo status before worker starts 

261 # Used to detect files incorrectly written to main repo 

262 baseline_status = self._get_main_repo_baseline() 

263 # Capture main HEAD SHA before worker starts to detect committed leaks 

264 baseline_head_sha = self._get_main_head_sha() 

265 

266 try: 

267 # Step 1: Create worktree with new branch 

268 self._setup_worktree( 

269 worktree_path, 

270 branch_name, 

271 base_branch=self.parallel_config.base_branch 

272 if self.parallel_config.use_feature_branches 

273 else None, 

274 ) 

275 

276 # Register worktree as active to prevent cleanup while in use (BUG-142) 

277 with self._process_lock: 

278 self._active_worktrees.add(worktree_path) 

279 

280 # Update stage for progress tracking (ENH-262) 

281 self.set_worker_stage(issue.issue_id, WorkerStage.VALIDATING) 

282 

283 # Step 2: Run ready-issue validation 

284 ready_cmd = self.parallel_config.get_ready_command(issue.issue_id) 

285 ready_result = self._run_claude_command( 

286 ready_cmd, 

287 worktree_path, 

288 issue_id=issue.issue_id, 

289 ) 

290 

291 # Check if worker was terminated during shutdown (ENH-036) 

292 if issue.issue_id in self._terminated_during_shutdown: 

293 self.set_worker_stage(issue.issue_id, WorkerStage.INTERRUPTED) 

294 return WorkerResult( 

295 issue_id=issue.issue_id, 

296 success=False, 

297 interrupted=True, 

298 branch_name=branch_name, 

299 worktree_path=worktree_path, 

300 duration=time.time() - start_time, 

301 error="Interrupted during shutdown", 

302 stdout=ready_result.stdout, 

303 stderr=ready_result.stderr, 

304 ) 

305 

306 if ready_result.returncode != 0: 

307 err_detail = ready_result.stderr or (ready_result.stdout or "")[:500] 

308 return WorkerResult( 

309 issue_id=issue.issue_id, 

310 success=False, 

311 branch_name=branch_name, 

312 worktree_path=worktree_path, 

313 duration=time.time() - start_time, 

314 error=f"ready-issue failed: {err_detail}", 

315 stdout=ready_result.stdout, 

316 stderr=ready_result.stderr, 

317 ) 

318 

319 # Step 3: Parse ready-issue output and check verdict 

320 ready_parsed = parse_ready_issue_output(ready_result.stdout) 

321 

322 # Handle CLOSE verdict - issue should not be implemented 

323 if ready_parsed.get("should_close"): 

324 return WorkerResult( 

325 issue_id=issue.issue_id, 

326 success=True, # Closure is a valid outcome 

327 branch_name=branch_name, 

328 worktree_path=worktree_path, 

329 duration=time.time() - start_time, 

330 should_close=True, 

331 close_reason=ready_parsed.get("close_reason"), 

332 close_status=ready_parsed.get("close_status"), 

333 stdout=ready_result.stdout, 

334 stderr=ready_result.stderr, 

335 ) 

336 

337 # Handle BLOCKED verdict - issue has open dependencies 

338 if ready_parsed.get("is_blocked"): 

339 return WorkerResult( 

340 issue_id=issue.issue_id, 

341 success=False, 

342 was_blocked=True, 

343 branch_name=branch_name, 

344 worktree_path=worktree_path, 

345 duration=time.time() - start_time, 

346 error="ready-issue verdict: BLOCKED - open dependency detected", 

347 stdout=ready_result.stdout, 

348 stderr=ready_result.stderr, 

349 ) 

350 

351 # Handle NOT_READY verdict 

352 if not ready_parsed["is_ready"]: 

353 concerns = ready_parsed.get("concerns", []) 

354 if concerns: 

355 concern_msg = "; ".join(concerns) 

356 elif ready_parsed["verdict"] == "UNKNOWN": 

357 # For UNKNOWN verdicts, show a snippet of output for debugging 

358 raw_out = (ready_result.stdout or "")[:200].strip() 

359 concern_msg = ( 

360 f"Could not parse verdict. Output: {raw_out}..." 

361 if raw_out 

362 else "No output from ready-issue" 

363 ) 

364 else: 

365 concern_msg = "Issue not ready" 

366 return WorkerResult( 

367 issue_id=issue.issue_id, 

368 success=False, 

369 branch_name=branch_name, 

370 worktree_path=worktree_path, 

371 duration=time.time() - start_time, 

372 error=f"ready-issue verdict: {ready_parsed['verdict']} - {concern_msg}", 

373 stdout=ready_result.stdout, 

374 stderr=ready_result.stderr, 

375 ) 

376 

377 # Track if issue was corrected (corrections stay in worktree) 

378 was_corrected = ready_parsed.get("was_corrected", False) 

379 corrections = ready_parsed.get("corrections", []) 

380 

381 # Update stage for progress tracking (ENH-262) 

382 self.set_worker_stage(issue.issue_id, WorkerStage.IMPLEMENTING) 

383 

384 # Decision gate: invoke decide-issue when the issue requires a decision 

385 if issue.decision_needed is True: 

386 decide_cmd = self.parallel_config.get_decide_command(issue.issue_id) 

387 decide_result = self._run_claude_command( 

388 decide_cmd, worktree_path, issue_id=issue.issue_id 

389 ) 

390 if decide_result.returncode != 0: 

391 self.logger.warning( 

392 f"[{issue.issue_id}] decide-issue command failed, " 

393 "continuing to implementation anyway..." 

394 ) 

395 

396 # Step 4: Get action from BRConfig 

397 action = self.br_config.get_category_action(issue.issue_type) 

398 

399 # Step 5: Run manage-issue implementation (with continuation support) 

400 manage_cmd = self.parallel_config.get_manage_command( 

401 issue.issue_type, action, issue.issue_id 

402 ) 

403 manage_result = self._run_with_continuation( 

404 manage_cmd, 

405 worktree_path, 

406 issue_id=issue.issue_id, 

407 ) 

408 

409 # Update stage for progress tracking (ENH-262) 

410 self.set_worker_stage(issue.issue_id, WorkerStage.VERIFYING) 

411 

412 # Check if worker was terminated during shutdown (ENH-036) 

413 if issue.issue_id in self._terminated_during_shutdown: 

414 self.set_worker_stage(issue.issue_id, WorkerStage.INTERRUPTED) 

415 return WorkerResult( 

416 issue_id=issue.issue_id, 

417 success=False, 

418 interrupted=True, 

419 branch_name=branch_name, 

420 worktree_path=worktree_path, 

421 duration=time.time() - start_time, 

422 error="Interrupted during shutdown", 

423 stdout=manage_result.stdout, 

424 stderr=manage_result.stderr, 

425 ) 

426 

427 # Step 6: Get list of changed files in worktree 

428 changed_files = self._get_changed_files(worktree_path) 

429 

430 # Step 8: Detect files leaked to main repo instead of worktree (unstaged) 

431 leaked_files = self._detect_main_repo_leaks(issue.issue_id, baseline_status) 

432 if leaked_files: 

433 self.logger.warning( 

434 f"{issue.issue_id} leaked {len(leaked_files)} file(s) to main repo: " 

435 f"{leaked_files}" 

436 ) 

437 # Clean up leaked files to prevent stash conflicts during merge. 

438 # The actual work is preserved in the worktree branch. 

439 self._cleanup_leaked_files(leaked_files) 

440 

441 # Step 8b: Detect commits made directly to main instead of worktree branch. 

442 # If Claude committed to main (not the worktree), worktree will have no diff, 

443 # causing work verification to fail. Attempt to recover by cherry-picking 

444 # the leaked commits to the worktree and resetting main. (BUG-580) 

445 committed_leaks = self._detect_committed_leaks(baseline_head_sha) 

446 if committed_leaks: 

447 self.logger.warning( 

448 f"{issue.issue_id} committed {len(committed_leaks)} commit(s) directly " 

449 f"to main instead of worktree: {[sha[:8] for sha in committed_leaks]}" 

450 ) 

451 if not changed_files: 

452 recovered = self._recover_committed_leaks( 

453 committed_leaks, worktree_path, baseline_head_sha, issue.issue_id 

454 ) 

455 if recovered: 

456 changed_files = self._get_changed_files(worktree_path) 

457 

458 # Step 7: Verify actual work was done (after potential committed-leak recovery) 

459 # Pass full filename for better doc-only keyword matching 

460 issue_filename = issue.path.stem if issue.path else "" 

461 work_verified, verification_error = self._verify_work_was_done( 

462 changed_files, issue.issue_id, issue_filename 

463 ) 

464 

465 if manage_result.returncode != 0: 

466 err_detail = manage_result.stderr or (manage_result.stdout or "")[:500] 

467 return WorkerResult( 

468 issue_id=issue.issue_id, 

469 success=False, 

470 branch_name=branch_name, 

471 worktree_path=worktree_path, 

472 changed_files=changed_files, 

473 leaked_files=leaked_files, 

474 duration=time.time() - start_time, 

475 error=f"manage-issue failed: {err_detail}", 

476 stdout=manage_result.stdout, 

477 stderr=manage_result.stderr, 

478 ) 

479 

480 if not work_verified: 

481 return WorkerResult( 

482 issue_id=issue.issue_id, 

483 success=False, 

484 branch_name=branch_name, 

485 worktree_path=worktree_path, 

486 changed_files=changed_files, 

487 leaked_files=leaked_files, 

488 duration=time.time() - start_time, 

489 error=verification_error, 

490 stdout=manage_result.stdout, 

491 stderr=manage_result.stderr, 

492 ) 

493 

494 # Step 9: Update branch base before merge (BUG-180) 

495 # Fetch origin/main and rebase to ensure branch is based on latest main 

496 base_updated, base_error = self._update_branch_base(worktree_path, issue.issue_id) 

497 

498 # Update stage for progress tracking (ENH-262) 

499 self.set_worker_stage(issue.issue_id, WorkerStage.MERGING) 

500 

501 if not base_updated: 

502 return WorkerResult( 

503 issue_id=issue.issue_id, 

504 success=False, 

505 branch_name=branch_name, 

506 worktree_path=worktree_path, 

507 changed_files=changed_files, 

508 leaked_files=leaked_files, 

509 duration=time.time() - start_time, 

510 error=base_error, 

511 stdout=manage_result.stdout, 

512 stderr=manage_result.stderr, 

513 ) 

514 

515 return WorkerResult( 

516 issue_id=issue.issue_id, 

517 success=True, 

518 branch_name=branch_name, 

519 worktree_path=worktree_path, 

520 changed_files=changed_files, 

521 leaked_files=leaked_files, 

522 duration=time.time() - start_time, 

523 error=None, 

524 stdout=manage_result.stdout, 

525 stderr=manage_result.stderr, 

526 was_corrected=was_corrected, 

527 corrections=corrections, 

528 ) 

529 

530 except Exception as e: 

531 return WorkerResult( 

532 issue_id=issue.issue_id, 

533 success=False, 

534 branch_name=branch_name, 

535 worktree_path=worktree_path, 

536 duration=time.time() - start_time, 

537 error=str(e), 

538 ) 

539 finally: 

540 # Unregister worktree as no longer active (BUG-142) 

541 with self._process_lock: 

542 self._active_worktrees.discard(worktree_path) 

543 

544 def _setup_worktree( 

545 self, worktree_path: Path, branch_name: str, base_branch: str | None = None 

546 ) -> None: 

547 """Create a git worktree with a new branch. 

548 

549 Args: 

550 worktree_path: Path for the new worktree 

551 branch_name: Name of the new branch 

552 base_branch: Optional commit-ish to fork the branch from; None forks from HEAD. 

553 """ 

554 from little_loops.worktree_utils import setup_worktree 

555 

556 setup_worktree( 

557 repo_path=self.repo_path, 

558 worktree_path=worktree_path, 

559 branch_name=branch_name, 

560 copy_files=self.parallel_config.worktree_copy_files, 

561 logger=self.logger, 

562 git_lock=self._git_lock, 

563 base_branch=base_branch, 

564 ) 

565 

566 # Verify model if --show-model flag is set (requires API call) 

567 if self.parallel_config.show_model: 

568 model = self._detect_worktree_model_via_api(worktree_path) 

569 if model: 

570 self.logger.info(f" Using model: {model}") 

571 else: 

572 self.logger.warning(" Could not detect Claude CLI model") 

573 

574 def _detect_worktree_model_via_api(self, worktree_path: Path) -> str | None: 

575 """Detect the model Claude will use by making an API call. 

576 

577 Runs a minimal Claude command with JSON output and parses the modelUsage 

578 field to verify settings.local.json is being respected. 

579 

580 Args: 

581 worktree_path: Path to the worktree to test 

582 

583 Returns: 

584 Model name (e.g., "claude-sonnet-4-20250514") or None if unable to detect 

585 """ 

586 try: 

587 invocation = resolve_host().build_blocking_json(prompt="reply with just 'ok'") 

588 # No-perm-skip preserved: this is a detection probe, not a real run. 

589 args = [a for a in invocation.args if a != "--dangerously-skip-permissions"] 

590 

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

592 # This ensures the first Claude CLI invocation in the worktree has the same 

593 # project root behavior as subsequent invocations via run_claude_command() 

594 env = os.environ.copy() 

595 env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1" 

596 env.update(invocation.env) 

597 

598 result = subprocess.run( 

599 [invocation.binary, *args], 

600 cwd=worktree_path, 

601 capture_output=True, 

602 text=True, 

603 timeout=30, 

604 env=env, 

605 ) 

606 if result.returncode == 0 and result.stdout.strip(): 

607 data: dict[str, Any] = json.loads(result.stdout.strip()) 

608 model_usage: dict[str, Any] = data.get("modelUsage", {}) 

609 # Return the first (primary) model from modelUsage 

610 if model_usage: 

611 return cast(str, next(iter(model_usage.keys()))) 

612 except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError): 

613 pass 

614 return None 

615 

616 def _cleanup_worktree(self, worktree_path: Path) -> None: 

617 """Remove a git worktree and its associated branch. 

618 

619 Args: 

620 worktree_path: Path to the worktree to remove 

621 """ 

622 if not worktree_path.exists(): 

623 return 

624 

625 # Skip cleanup if worktree is actively in use by a running worker (BUG-142) 

626 with self._process_lock: 

627 if worktree_path in self._active_worktrees: 

628 self.logger.warning( 

629 f"Skipping cleanup of {worktree_path.name}: worktree is in active use" 

630 ) 

631 return 

632 

633 # Only delete branches with the parallel/ prefix (legacy behavior for ll-parallel) 

634 branch_result = subprocess.run( 

635 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 

636 cwd=worktree_path, 

637 capture_output=True, 

638 text=True, 

639 ) 

640 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None 

641 delete_branch = branch_name is not None and branch_name.startswith("parallel/") 

642 

643 from little_loops.worktree_utils import cleanup_worktree 

644 

645 cleanup_worktree( 

646 worktree_path=worktree_path, 

647 repo_path=self.repo_path, 

648 logger=self.logger, 

649 git_lock=self._git_lock, 

650 delete_branch=delete_branch, 

651 ) 

652 

653 def _run_claude_command( 

654 self, 

655 command: str, 

656 working_dir: Path, 

657 issue_id: str | None = None, 

658 on_usage: Callable[[int, int], None] | None = None, 

659 resume_session: bool = False, 

660 ) -> subprocess.CompletedProcess[str]: 

661 """Run a Claude CLI command with real-time output streaming. 

662 

663 Args: 

664 command: The command to run (e.g., "/ll:ready-issue BUG-123") 

665 working_dir: Directory to run the command in 

666 issue_id: Optional issue ID for subprocess tracking 

667 on_usage: Optional usage callback for token tracking 

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

669 

670 Returns: 

671 CompletedProcess with stdout and stderr 

672 """ 

673 stream_output = self.parallel_config.stream_subprocess_output 

674 

675 def stream_callback(line: str, is_stderr: bool) -> None: 

676 if stream_output: 

677 if is_stderr: 

678 print(f" {line}", file=sys.stderr) 

679 else: 

680 self.logger.info(f" {line}") 

681 

682 def on_start(process: subprocess.Popen[str]) -> None: 

683 if issue_id: 

684 with self._process_lock: 

685 self._active_processes[issue_id] = process 

686 

687 def on_end(process: subprocess.Popen[str]) -> None: 

688 if issue_id: 

689 with self._process_lock: 

690 self._active_processes.pop(issue_id, None) 

691 

692 return _run_claude_base( 

693 command=command, 

694 timeout=self.parallel_config.timeout_per_issue, 

695 working_dir=working_dir, 

696 stream_callback=stream_callback if stream_output else None, 

697 on_process_start=on_start if issue_id else None, 

698 on_process_end=on_end if issue_id else None, 

699 idle_timeout=self.parallel_config.idle_timeout_per_issue, 

700 on_usage=on_usage, 

701 resume_session=resume_session, 

702 ) 

703 

704 def _check_issue_already_done(self, issue_id: str | None, working_dir: Path) -> bool: 

705 """Check if the issue file's status indicates work is already complete. 

706 

707 Pre-continuation guard (BUG-1759): when the inner Claude session hits its 

708 context limit but the issue was already marked done, skip the handoff and 

709 return success rather than triggering an unnecessary handoff cycle. 

710 

711 Args: 

712 issue_id: Issue identifier (e.g., "BUG-1759"), or None. 

713 working_dir: Working directory (worktree) to search for issue files. 

714 

715 Returns: 

716 True if the issue's status is 'done' or 'cancelled'. 

717 """ 

718 if issue_id is None: 

719 return False 

720 issues_dir = working_dir / ".issues" 

721 if not issues_dir.exists(): 

722 return False 

723 try: 

724 from little_loops.frontmatter import parse_frontmatter 

725 

726 # Search all category directories for the issue file 

727 for cat_dir in issues_dir.iterdir(): 

728 if not cat_dir.is_dir(): 

729 continue 

730 for f in cat_dir.iterdir(): 

731 if not f.is_file() or not f.suffix == ".md": 

732 continue 

733 if f"-{issue_id}-" in f.name or f.name.endswith(f"-{issue_id}.md"): 

734 fm = parse_frontmatter(f.read_text(encoding="utf-8")) 

735 return fm.get("status") in ("done", "cancelled") 

736 return False 

737 except Exception: 

738 return False 

739 

740 def _run_with_continuation( 

741 self, 

742 command: str, 

743 working_dir: Path, 

744 issue_id: str | None = None, 

745 max_continuations: int = 3, 

746 context_limit: int = 200_000, 

747 sentinel_threshold: float = 0.60, 

748 guillotine_threshold: float = 0.90, 

749 run_dir: str | None = None, 

750 sprint_context: SprintWorkerContext | None = None, 

751 ) -> subprocess.CompletedProcess[str]: 

752 """Run a Claude command with automatic continuation on context handoff. 

753 

754 Mirrors the E+G+J logic in issue_manager.run_with_continuation. 

755 

756 Args: 

757 command: The command to run 

758 working_dir: Directory (worktree) to run the command in 

759 issue_id: Optional issue ID for subprocess tracking 

760 max_continuations: Maximum number of continuation attempts 

761 context_limit: Context window size in tokens 

762 sentinel_threshold: Write sentinel when usage >= this fraction 

763 guillotine_threshold: Trigger J-path when usage >= this fraction 

764 

765 Returns: 

766 Combined CompletedProcess with all session outputs 

767 """ 

768 all_stdout: list[str] = [] 

769 all_stderr: list[str] = [] 

770 current_command = command 

771 continuation_count = 0 

772 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess( 

773 args=[], returncode=1, stdout="", stderr="" 

774 ) 

775 tag = f"[{issue_id}]" if issue_id else "[worker]" 

776 

777 # Track token usage per-round for sentinel/guillotine thresholds 

778 _last_input: list[int] = [0] 

779 _last_output: list[int] = [0] 

780 

781 def _usage_tracker(input_tokens: int, output_tokens: int) -> None: 

782 _last_input[0] = input_tokens 

783 _last_output[0] = output_tokens 

784 

785 while continuation_count <= max_continuations: 

786 result = self._run_claude_command( 

787 current_command, 

788 working_dir, 

789 issue_id=issue_id, 

790 on_usage=_usage_tracker, 

791 ) 

792 

793 all_stdout.append(result.stdout) 

794 all_stderr.append(result.stderr) 

795 

796 # Standard path: Claude emitted CONTEXT_HANDOFF 

797 if detect_context_handoff(result.stdout): 

798 self.logger.info(f"{tag} Detected CONTEXT_HANDOFF signal") 

799 

800 # Pre-continuation guard: if the issue is already done/cancelled, 

801 # the work is complete — return success without signalling handoff 

802 # so the outer FSM doesn't waste a handoff cycle on finished work. 

803 already_done = self._check_issue_already_done(issue_id, working_dir) 

804 if already_done: 

805 self.logger.info( 

806 f"{tag} Issue already done/cancelled; " 

807 "skipping handoff and returning success" 

808 ) 

809 result = subprocess.CompletedProcess( 

810 args=result.args, 

811 returncode=0, 

812 stdout=result.stdout, 

813 stderr=result.stderr, 

814 ) 

815 break 

816 

817 # Forward CONTEXT_HANDOFF signal to stdout so the outer FSM's 

818 # signal_detector can detect it via the existing HANDOFF_SIGNAL pattern. 

819 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

820 print(handoff_message) 

821 self.logger.info(f"{tag} Forwarded handoff signal to stdout; exiting cleanly") 

822 

823 result = subprocess.CompletedProcess( 

824 args=result.args, 

825 returncode=0, 

826 stdout=result.stdout + "\n" + handoff_message, 

827 stderr=result.stderr, 

828 ) 

829 break 

830 

831 total_tokens = _last_input[0] + _last_output[0] 

832 usage_ratio = total_tokens / context_limit if context_limit > 0 else 0.0 

833 prompt_too_long = "prompt is too long" in (result.stderr or "").lower() 

834 

835 # Option J: guillotine — fresh session. 

836 # When run_dir is set (loop context), write a resume file and invoke /ll:resume. 

837 # Otherwise fall back to the transcript-summary blob. 

838 if ( 

839 prompt_too_long or usage_ratio >= guillotine_threshold 

840 ) and continuation_count < max_continuations: 

841 trigger_reason = ( 

842 "Prompt is too long" if prompt_too_long else f"usage {usage_ratio:.0%}" 

843 ) 

844 self.logger.warning( 

845 f"{tag} Option J triggered ({trigger_reason}): spawning fresh session" 

846 ) 

847 if run_dir is not None: 

848 try: 

849 guillotine_file = Path(run_dir) / "guillotine-prompt.md" 

850 guillotine_file.parent.mkdir(parents=True, exist_ok=True) 

851 task_first_line = (command.strip().splitlines() or [""])[0] 

852 sprint_framing = "" 

853 if sprint_context is not None: 

854 sprint_framing = ( 

855 f"## Sprint Worker Context\n" 

856 f"You are a sprint worker. Process exactly ONE issue: " 

857 f"{sprint_context.issue_id}\n" 

858 f"After completing this issue, exit immediately — " 

859 f"do NOT process other issues.\n" 

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

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

862 ) 

863 guillotine_file.write_text( 

864 sprint_framing + f"## Intent\n" 

865 f"Resume an interrupted automation session that hit the context limit.\n" 

866 f"Original task: {task_first_line}\n" 

867 f"Trigger reason: {trigger_reason} " 

868 f"({_last_input[0] + _last_output[0]:,} / {context_limit:,} tokens)\n" 

869 f"\n" 

870 f"## Next Steps\n" 

871 f"1. Check `git log` to see what was committed in the previous session\n" 

872 f"2. Check the issue file status — if already done/cancelled, stop\n" 

873 f"3. Review `.loops/tmp/scratch/` for partial progress notes\n" 

874 f"4. Continue the original task from where it left off, " 

875 f"skipping already-completed work\n", 

876 encoding="utf-8", 

877 ) 

878 guillotine_cmd = f"/ll:resume {guillotine_file}" 

879 self.logger.info(f"{tag} Option J resume file written: {guillotine_file}") 

880 except Exception as exc: 

881 self.logger.warning( 

882 f"{tag} Failed to write guillotine resume file ({exc}), " 

883 "falling back to summary blob" 

884 ) 

885 guillotine_cmd = command 

886 else: 

887 try: 

888 guillotine_cmd = assemble_guillotine_prompt( 

889 original_command=command, 

890 captured_stdout="\n---CONTINUATION---\n".join(all_stdout), 

891 token_stats={ 

892 "input_tokens": _last_input[0], 

893 "output_tokens": _last_output[0], 

894 "context_limit": context_limit, 

895 "trigger_reason": trigger_reason, 

896 }, 

897 sprint_context=sprint_context, 

898 ) 

899 except Exception as exc: 

900 self.logger.warning( 

901 f"{tag} Failed to assemble guillotine prompt ({exc}), " 

902 "using bare restart" 

903 ) 

904 guillotine_cmd = command 

905 continuation_count += 1 

906 current_command = guillotine_cmd 

907 _last_input[0] = 0 

908 _last_output[0] = 0 

909 continue 

910 

911 # Option E: read sentinel from a PREVIOUS session (must run before G writes 

912 # the current-session sentinel to avoid immediately consuming our own write). 

913 sentinel_data = read_sentinel(working_dir) 

914 if sentinel_data is not None and continuation_count < max_continuations: 

915 usage_pct = sentinel_data.get("usage_percent", int(usage_ratio * 100)) 

916 self.logger.info( 

917 f"{tag} Sentinel detected ({usage_pct}% context used): " 

918 "sending explicit handoff instruction" 

919 ) 

920 continuation_count += 1 

921 explicit_handoff_instruction = ( 

922 f"Context limit is approaching ({usage_pct}% of the context window is used). " 

923 "Please run /ll:handoff RIGHT NOW to save your progress to " 

924 ".ll/ll-continue-prompt.md, then output " 

925 '"CONTEXT_HANDOFF: Ready for fresh session" to signal continuation.' 

926 ) 

927 _last_input[0] = 0 

928 _last_output[0] = 0 

929 result = self._run_claude_command( 

930 explicit_handoff_instruction, 

931 working_dir, 

932 issue_id=issue_id, 

933 on_usage=_usage_tracker, 

934 resume_session=True, 

935 ) 

936 all_stdout.append(result.stdout) 

937 all_stderr.append(result.stderr) 

938 

939 if detect_context_handoff(result.stdout): 

940 self.logger.info( 

941 f"{tag} CONTEXT_HANDOFF detected after explicit handoff instruction" 

942 ) 

943 prompt_content = read_continuation_prompt(working_dir) 

944 if prompt_content and continuation_count < max_continuations: 

945 continuation_count += 1 

946 self.logger.info( 

947 f"{tag} Starting continuation session #{continuation_count}" 

948 ) 

949 current_command = f"{command} --resume" 

950 _last_input[0] = 0 

951 _last_output[0] = 0 

952 continue 

953 break 

954 

955 # Option G (Python layer): write sentinel for the NEXT session. 

956 # Placed after E-path so we don't immediately consume our own write. 

957 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

958 self.logger.info( 

959 f"{tag} Writing context-handoff sentinel ({usage_ratio:.0%} context used)" 

960 ) 

961 write_sentinel(working_dir, token_count=total_tokens, context_limit=context_limit) 

962 

963 # No handoff signal, no prior-session sentinel, no overflow — done 

964 break 

965 

966 return subprocess.CompletedProcess( 

967 args=result.args, 

968 returncode=result.returncode, 

969 stdout="\n---CONTINUATION---\n".join(all_stdout), 

970 stderr="\n---CONTINUATION---\n".join(all_stderr), 

971 ) 

972 

973 def _get_changed_files(self, worktree_path: Path) -> list[str]: 

974 """Get list of files changed in the worktree. 

975 

976 Args: 

977 worktree_path: Path to the worktree 

978 

979 Returns: 

980 List of changed file paths relative to repo root 

981 """ 

982 result = subprocess.run( 

983 ["git", "diff", "--name-only", self.parallel_config.base_branch, "HEAD"], 

984 cwd=worktree_path, 

985 capture_output=True, 

986 text=True, 

987 timeout=30, 

988 ) 

989 

990 if result.returncode != 0: 

991 return [] 

992 

993 return [f.strip() for f in result.stdout.strip().split("\n") if f.strip()] 

994 

995 def _update_branch_base(self, worktree_path: Path, issue_id: str) -> tuple[bool, str]: 

996 """Fetch origin/main and rebase worker branch onto it. 

997 

998 This ensures the worker branch is based on the latest main before 

999 merge coordination, preventing conflicts when main advances during 

1000 sprint execution (BUG-180). 

1001 

1002 Args: 

1003 worktree_path: Path to the worker's worktree 

1004 issue_id: Issue ID for logging 

1005 

1006 Returns: 

1007 Tuple of (success, error_message) 

1008 """ 

1009 # Fetch latest base branch from configured remote (fall back to local if fetch fails) 

1010 base = self.parallel_config.base_branch 

1011 remote = self.parallel_config.remote_name 

1012 fetch_result = subprocess.run( 

1013 ["git", "fetch", remote, base], 

1014 cwd=worktree_path, 

1015 capture_output=True, 

1016 text=True, 

1017 timeout=60, 

1018 ) 

1019 

1020 rebase_target = f"{remote}/{base}" if fetch_result.returncode == 0 else base 

1021 

1022 # Rebase current branch onto base (remote or local fallback) 

1023 rebase_result = subprocess.run( 

1024 ["git", "rebase", rebase_target], 

1025 cwd=worktree_path, 

1026 capture_output=True, 

1027 text=True, 

1028 timeout=120, 

1029 ) 

1030 

1031 if rebase_result.returncode != 0: 

1032 # Abort the failed rebase 

1033 subprocess.run( 

1034 ["git", "rebase", "--abort"], 

1035 cwd=worktree_path, 

1036 capture_output=True, 

1037 timeout=10, 

1038 ) 

1039 return False, f"Failed to rebase onto {rebase_target}: {rebase_result.stderr}" 

1040 

1041 self.logger.info(f"[{issue_id}] Rebased branch onto {rebase_target}") 

1042 return True, "" 

1043 

1044 def _verify_work_was_done( 

1045 self, changed_files: list[str], issue_id: str, issue_filename: str = "" 

1046 ) -> tuple[bool, str]: 

1047 """Verify that actual implementation work was done. 

1048 

1049 Uses the shared verify_work_was_done() function to check that changed 

1050 files include meaningful work, not just issue files or other artifacts. 

1051 

1052 Args: 

1053 changed_files: List of files changed during processing 

1054 issue_id: The issue ID being processed (unused, kept for compatibility) 

1055 issue_filename: Full issue filename (unused, kept for compatibility) 

1056 

1057 Returns: 

1058 Tuple of (success, error_message) 

1059 """ 

1060 if not changed_files: 

1061 return False, "No files were changed during implementation" 

1062 

1063 # Check if code changes are required 

1064 if not self.parallel_config.require_code_changes: 

1065 return True, "" 

1066 

1067 # Use shared verification function 

1068 if verify_work_was_done(self.logger, changed_files): 

1069 return True, "" 

1070 

1071 # Generate descriptive error with actual excluded files 

1072 excluded_files = [ 

1073 f 

1074 for f in changed_files 

1075 if f and any(f.startswith(excl) for excl in EXCLUDED_DIRECTORIES) 

1076 ] 

1077 if excluded_files: 

1078 files_preview = ", ".join(excluded_files[:5]) 

1079 if len(excluded_files) > 5: 

1080 files_preview += f" (+{len(excluded_files) - 5} more)" 

1081 return False, f"Only excluded files modified: {files_preview}" 

1082 return False, "Only excluded files modified (e.g., .issues/, thoughts/)" 

1083 

1084 def _has_other_issue_id(self, file_lower: str, current_issue_id_lower: str) -> bool: 

1085 """Check if file contains a different issue ID than the current worker's. 

1086 

1087 This prevents cross-worker contamination where worker A detects worker B's 

1088 leaked file. When multiple workers run in parallel, their leaked files may 

1089 both appear in the main repo. Each worker should only clean up its own leaks. 

1090 

1091 Args: 

1092 file_lower: Lowercase file path to check 

1093 current_issue_id_lower: Lowercase issue ID of the current worker 

1094 

1095 Returns: 

1096 True if the file contains a different issue ID (belongs to another worker), 

1097 False if the file contains the current issue ID or no recognizable issue ID 

1098 """ 

1099 # Pattern matches common issue ID formats: BUG-123, ENH-456, FEAT-789, EPIC-001 

1100 # Use non-capturing group (?:...) so findall returns full match, not group 

1101 matches = re.findall(r"(?:bug|enh|feat|epic)-\d+", file_lower) 

1102 

1103 if not matches: 

1104 # No issue ID found - file doesn't belong to any specific worker 

1105 return False 

1106 

1107 # Check if any of the found issue IDs match the current worker 

1108 for match in matches: 

1109 if match == current_issue_id_lower: 

1110 return False # File belongs to current worker 

1111 

1112 # File has issue ID(s) but none match current worker - belongs to another worker 

1113 return True 

1114 

1115 def _detect_main_repo_leaks(self, issue_id: str, baseline_status: set[str]) -> list[str]: 

1116 """Detect files incorrectly written to main repo instead of worktree. 

1117 

1118 Claude Code may write files to the main repository instead of the 

1119 worktree due to project root detection issues (see GitHub #8771). 

1120 This method detects such leaks by comparing main repo status before 

1121 and after worker execution. 

1122 

1123 Args: 

1124 issue_id: ID of the issue being processed (for pattern matching) 

1125 baseline_status: Set of file paths from git status before worker started 

1126 

1127 Returns: 

1128 List of file paths that were leaked to main repo 

1129 """ 

1130 # Get current status of main repo 

1131 result = self._git_lock.run( 

1132 ["status", "--porcelain"], 

1133 cwd=self.repo_path, 

1134 timeout=30, 

1135 ) 

1136 

1137 if result.returncode != 0: 

1138 return [] 

1139 

1140 current_files: set[str] = set() 

1141 for line in result.stdout.strip().split("\n"): 

1142 if not line or len(line) < 3: 

1143 continue 

1144 # Extract file path (after status codes and space) 

1145 file_path = line[3:].strip() 

1146 # Handle renamed files (old -> new) 

1147 if " -> " in file_path: 

1148 file_path = file_path.split(" -> ")[-1] 

1149 current_files.add(file_path) 

1150 

1151 # Find new files that appeared during worker execution 

1152 new_files = current_files - baseline_status 

1153 

1154 # Filter to files likely related to this issue 

1155 issue_id_lower = issue_id.lower() 

1156 leaked_files: list[str] = [] 

1157 

1158 # Build source prefix list: start with common fallbacks, then add configured dirs 

1159 source_prefixes = ["backend/", "src/", "lib/", "tests/"] 

1160 for dir_path in [self.br_config.project.src_dir, self.br_config.project.test_dir]: 

1161 if dir_path: 

1162 normalized = dir_path.rstrip("/") + "/" 

1163 if normalized not in source_prefixes: 

1164 source_prefixes.append(normalized) 

1165 

1166 for file_path in new_files: 

1167 # Skip state file (managed by orchestrator) 

1168 if file_path.endswith(".parallel-manage-state.json"): 

1169 continue 

1170 # Skip .gitignore (may be modified by ll-parallel) 

1171 if file_path == ".gitignore": 

1172 continue 

1173 

1174 # Check if file is related to this issue 

1175 file_lower = file_path.lower() 

1176 if issue_id_lower in file_lower: 

1177 leaked_files.append(file_path) 

1178 # Also catch source files that shouldn't be modified in main 

1179 elif file_path.startswith(tuple(source_prefixes)): 

1180 leaked_files.append(file_path) 

1181 # Catch thoughts/plans files 

1182 elif file_path.startswith("thoughts/"): 

1183 leaked_files.append(file_path) 

1184 # Catch issue files in any issue directory variant 

1185 # Handles both .issues/ (with dot) and issues/ (without dot) 

1186 # Only include files without a different issue ID - files WITH other issue IDs 

1187 # belong to other workers running in parallel (cross-worker contamination) 

1188 elif file_path.startswith((".issues/", "issues/")): 

1189 if not self._has_other_issue_id(file_lower, issue_id_lower): 

1190 leaked_files.append(file_path) 

1191 

1192 return leaked_files 

1193 

1194 def _cleanup_leaked_files(self, leaked_files: list[str]) -> int: 

1195 """Discard leaked files from main repo working directory. 

1196 

1197 Claude Code sometimes writes files to the main repo instead of the 

1198 worktree. These files cause stash conflicts during merge operations. 

1199 Since the actual work is preserved in the worktree branch, we can 

1200 safely discard these leaked changes from the main repo. 

1201 

1202 Args: 

1203 leaked_files: List of file paths leaked to main repo 

1204 

1205 Returns: 

1206 Number of files successfully cleaned up 

1207 """ 

1208 if not leaked_files: 

1209 return 0 

1210 

1211 cleaned = 0 

1212 

1213 # Get status to determine which files are tracked vs untracked 

1214 status_result = self._git_lock.run( 

1215 ["status", "--porcelain", "--"] + leaked_files, 

1216 cwd=self.repo_path, 

1217 timeout=30, 

1218 ) 

1219 

1220 tracked_files: list[str] = [] 

1221 untracked_files: list[str] = [] 

1222 

1223 for line in status_result.stdout.splitlines(): 

1224 if not line or len(line) < 3: 

1225 continue 

1226 status_code = line[:2] 

1227 file_path = line[3:].split(" -> ")[-1].strip() 

1228 

1229 if status_code.startswith("?"): 

1230 # Untracked file - need to delete 

1231 untracked_files.append(file_path) 

1232 else: 

1233 # Tracked file - can use git checkout to discard 

1234 tracked_files.append(file_path) 

1235 

1236 # Discard changes to tracked files 

1237 if tracked_files: 

1238 checkout_result = self._git_lock.run( 

1239 ["checkout", "--"] + tracked_files, 

1240 cwd=self.repo_path, 

1241 timeout=30, 

1242 ) 

1243 if checkout_result.returncode == 0: 

1244 cleaned += len(tracked_files) 

1245 else: 

1246 self.logger.warning( 

1247 f"Failed to discard tracked leaked files: {checkout_result.stderr}" 

1248 ) 

1249 

1250 # Delete untracked files 

1251 for file_path in untracked_files: 

1252 full_path = self.repo_path / file_path 

1253 try: 

1254 if full_path.exists(): 

1255 full_path.unlink() 

1256 cleaned += 1 

1257 except OSError as e: 

1258 self.logger.warning(f"Failed to delete leaked file {file_path}: {e}") 

1259 

1260 # Fallback: directly delete files not reported by git status 

1261 # This handles gitignored files that git status --porcelain doesn't show 

1262 accounted_files = set(tracked_files + untracked_files) 

1263 for file_path in leaked_files: 

1264 if file_path not in accounted_files: 

1265 full_path = self.repo_path / file_path 

1266 if full_path.exists(): 

1267 try: 

1268 full_path.unlink() 

1269 cleaned += 1 

1270 self.logger.info(f"Deleted gitignored leaked file: {file_path}") 

1271 except OSError as e: 

1272 self.logger.warning( 

1273 f"Failed to delete gitignored leaked file {file_path}: {e}" 

1274 ) 

1275 else: 

1276 self.logger.debug(f"Leaked file not found (may have been moved): {file_path}") 

1277 

1278 if cleaned > 0: 

1279 self.logger.info(f"Cleaned up {cleaned} leaked file(s) from main repo") 

1280 

1281 return cleaned 

1282 

1283 def _get_main_repo_baseline(self) -> set[str]: 

1284 """Get baseline of modified/untracked files in main repo. 

1285 

1286 Returns: 

1287 Set of file paths currently showing in git status 

1288 """ 

1289 result = self._git_lock.run( 

1290 ["status", "--porcelain"], 

1291 cwd=self.repo_path, 

1292 timeout=30, 

1293 ) 

1294 

1295 if result.returncode != 0: 

1296 return set() 

1297 

1298 files: set[str] = set() 

1299 for line in result.stdout.strip().split("\n"): 

1300 if not line or len(line) < 3: 

1301 continue 

1302 file_path = line[3:].strip() 

1303 if " -> " in file_path: 

1304 file_path = file_path.split(" -> ")[-1] 

1305 files.add(file_path) 

1306 

1307 return files 

1308 

1309 def _get_main_head_sha(self) -> str: 

1310 """Get the current HEAD SHA of the main repo. 

1311 

1312 Returns: 

1313 HEAD SHA string, or empty string if unavailable 

1314 """ 

1315 result = self._git_lock.run( 

1316 ["rev-parse", "HEAD"], 

1317 cwd=self.repo_path, 

1318 timeout=10, 

1319 ) 

1320 if result.returncode == 0: 

1321 return result.stdout.strip() 

1322 return "" 

1323 

1324 def _detect_committed_leaks(self, baseline_head_sha: str) -> list[str]: 

1325 """Detect commits made directly to main repo during worker execution. 

1326 

1327 When Claude commits to the main repo instead of the worktree branch, 

1328 the commits appear on main's history but the worktree has no changes. 

1329 This method detects such leaked commits by comparing main's HEAD SHA 

1330 before and after worker execution. 

1331 

1332 Args: 

1333 baseline_head_sha: HEAD SHA captured before worker started 

1334 

1335 Returns: 

1336 List of commit SHAs committed to main during worker execution, 

1337 newest first. Empty list if no committed leaks detected. 

1338 """ 

1339 if not baseline_head_sha: 

1340 return [] 

1341 

1342 current_sha = self._get_main_head_sha() 

1343 if not current_sha or current_sha == baseline_head_sha: 

1344 return [] 

1345 

1346 # Get list of new commits on main since baseline 

1347 result = self._git_lock.run( 

1348 ["log", "--format=%H", f"{baseline_head_sha}..HEAD"], 

1349 cwd=self.repo_path, 

1350 timeout=30, 

1351 ) 

1352 if result.returncode != 0: 

1353 return [] 

1354 

1355 commits = [sha.strip() for sha in result.stdout.strip().split("\n") if sha.strip()] 

1356 return commits 

1357 

1358 def _recover_committed_leaks( 

1359 self, 

1360 leaked_commits: list[str], 

1361 worktree_path: Path, 

1362 baseline_head_sha: str, 

1363 issue_id: str, 

1364 ) -> bool: 

1365 """Attempt to recover committed leaks by cherry-picking to worktree. 

1366 

1367 When Claude commits directly to main instead of the worktree branch, 

1368 we attempt to: 

1369 1. Cherry-pick the leaked commits onto the worktree branch 

1370 2. Reset main back to the baseline SHA (if safe to do so) 

1371 

1372 This preserves the implementation work in the worktree while 

1373 cleaning up the incorrect commits on main. 

1374 

1375 Args: 

1376 leaked_commits: Commit SHAs that leaked to main (newest first) 

1377 worktree_path: Path to the worker's worktree 

1378 baseline_head_sha: Main HEAD SHA before worker started 

1379 issue_id: Issue ID for logging 

1380 

1381 Returns: 

1382 True if cherry-pick succeeded (main reset is attempted but 

1383 not required for a True return value) 

1384 """ 

1385 self.logger.info( 

1386 f"[{issue_id}] Attempting recovery: cherry-picking {len(leaked_commits)} " 

1387 f"commit(s) to worktree" 

1388 ) 

1389 

1390 # Cherry-pick in chronological order (oldest first = reverse of log output) 

1391 for sha in reversed(leaked_commits): 

1392 result = subprocess.run( 

1393 ["git", "cherry-pick", sha], 

1394 cwd=worktree_path, 

1395 capture_output=True, 

1396 text=True, 

1397 timeout=60, 

1398 ) 

1399 if result.returncode != 0: 

1400 subprocess.run( 

1401 ["git", "cherry-pick", "--abort"], 

1402 cwd=worktree_path, 

1403 capture_output=True, 

1404 timeout=10, 

1405 ) 

1406 self.logger.warning( 

1407 f"[{issue_id}] Cherry-pick of {sha[:8]} failed: {result.stderr.strip()}" 

1408 ) 

1409 return False 

1410 

1411 # Attempt to reset main to baseline (only if main hasn't advanced further) 

1412 current_main_sha = self._get_main_head_sha() 

1413 most_recent_leaked = leaked_commits[0] # Newest first 

1414 if current_main_sha == most_recent_leaked: 

1415 reset_result = self._git_lock.run( 

1416 ["reset", "--hard", baseline_head_sha], 

1417 cwd=self.repo_path, 

1418 timeout=30, 

1419 ) 

1420 if reset_result.returncode == 0: 

1421 self.logger.info(f"[{issue_id}] Reset main to baseline {baseline_head_sha[:8]}") 

1422 else: 

1423 self.logger.warning( 

1424 f"[{issue_id}] Cherry-pick succeeded but failed to reset main: " 

1425 f"{reset_result.stderr.strip()}" 

1426 ) 

1427 else: 

1428 # main has advanced past the leaked commits — attempt surgical rebase 

1429 # to excise only the leaked commits while preserving subsequent work 

1430 self.logger.info( 

1431 f"[{issue_id}] Main has advanced beyond leaked commits " 

1432 f"({current_main_sha[:8]} != {most_recent_leaked[:8]}) — " 

1433 f"attempting surgical rebase to excise leaked commits" 

1434 ) 

1435 rebase_result = self._git_lock.run( 

1436 ["rebase", "--onto", baseline_head_sha, most_recent_leaked], 

1437 cwd=self.repo_path, 

1438 timeout=60, 

1439 ) 

1440 if rebase_result.returncode == 0: 

1441 self.logger.info(f"[{issue_id}] Surgically removed leaked commits via rebase") 

1442 else: 

1443 self._git_lock.run( 

1444 ["rebase", "--abort"], 

1445 cwd=self.repo_path, 

1446 timeout=10, 

1447 ) 

1448 self.logger.warning( 

1449 f"[{issue_id}] Surgical rebase failed — manual cleanup required: " 

1450 f"{rebase_result.stderr.strip()}" 

1451 ) 

1452 

1453 self.logger.info( 

1454 f"[{issue_id}] Recovered {len(leaked_commits)} commit(s): " 

1455 f"cherry-picked to worktree branch" 

1456 ) 

1457 return True 

1458 

1459 @property 

1460 def active_count(self) -> int: 

1461 """Number of currently active workers. 

1462 

1463 Includes both workers with running futures AND workers whose futures 

1464 are done but callbacks haven't completed yet. 

1465 """ 

1466 with self._process_lock: 

1467 running_futures = sum(1 for f in self._active_workers.values() if not f.done()) 

1468 with self._callback_lock: 

1469 pending_callback_count = len(self._pending_callbacks) 

1470 return running_futures + pending_callback_count 

1471 

1472 def set_worker_stage(self, issue_id: str, stage: WorkerStage) -> None: 

1473 """Update the stage of a worker. 

1474 

1475 Args: 

1476 issue_id: Issue ID being processed 

1477 stage: New stage value 

1478 """ 

1479 with self._process_lock: 

1480 self._worker_stages[issue_id] = stage 

1481 

1482 def get_worker_stage(self, issue_id: str) -> WorkerStage | None: 

1483 """Get the current stage of a worker. 

1484 

1485 Args: 

1486 issue_id: Issue ID being processed 

1487 

1488 Returns: 

1489 Current stage, or None if issue not being tracked 

1490 """ 

1491 with self._process_lock: 

1492 return self._worker_stages.get(issue_id) 

1493 

1494 def get_active_stages(self) -> dict[str, WorkerStage]: 

1495 """Get all active worker stages. 

1496 

1497 Returns: 

1498 Dictionary mapping issue_id to current stage for active workers 

1499 """ 

1500 with self._process_lock: 

1501 # Only return workers that are actually active 

1502 active_ids = set(self._active_workers.keys()) 

1503 return { 

1504 issue_id: stage 

1505 for issue_id, stage in self._worker_stages.items() 

1506 if issue_id in active_ids 

1507 } 

1508 

1509 def remove_worker_stage(self, issue_id: str) -> None: 

1510 """Remove a worker from stage tracking. 

1511 

1512 Args: 

1513 issue_id: Issue ID to remove 

1514 """ 

1515 with self._process_lock: 

1516 self._worker_stages.pop(issue_id, None) 

1517 

1518 def cleanup_all_worktrees(self) -> None: 

1519 """Clean up all worker worktrees.""" 

1520 worktree_base = self.repo_path / self.parallel_config.worktree_base 

1521 if not worktree_base.exists(): 

1522 return 

1523 

1524 from little_loops.worktree_utils import _is_ll_worktree 

1525 

1526 for worktree_dir in worktree_base.iterdir(): 

1527 if worktree_dir.is_dir() and _is_ll_worktree(worktree_dir.name): 

1528 self._cleanup_worktree(worktree_dir) 

1529 

1530 self.logger.info("Cleaned up all worker worktrees")