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

538 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -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(worktree_path, branch_name) 

269 

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

271 with self._process_lock: 

272 self._active_worktrees.add(worktree_path) 

273 

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

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

276 

277 # Step 2: Run ready-issue validation 

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

279 ready_result = self._run_claude_command( 

280 ready_cmd, 

281 worktree_path, 

282 issue_id=issue.issue_id, 

283 ) 

284 

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

286 if issue.issue_id in self._terminated_during_shutdown: 

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

288 return WorkerResult( 

289 issue_id=issue.issue_id, 

290 success=False, 

291 interrupted=True, 

292 branch_name=branch_name, 

293 worktree_path=worktree_path, 

294 duration=time.time() - start_time, 

295 error="Interrupted during shutdown", 

296 stdout=ready_result.stdout, 

297 stderr=ready_result.stderr, 

298 ) 

299 

300 if ready_result.returncode != 0: 

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

302 return WorkerResult( 

303 issue_id=issue.issue_id, 

304 success=False, 

305 branch_name=branch_name, 

306 worktree_path=worktree_path, 

307 duration=time.time() - start_time, 

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

309 stdout=ready_result.stdout, 

310 stderr=ready_result.stderr, 

311 ) 

312 

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

314 ready_parsed = parse_ready_issue_output(ready_result.stdout) 

315 

316 # Handle CLOSE verdict - issue should not be implemented 

317 if ready_parsed.get("should_close"): 

318 return WorkerResult( 

319 issue_id=issue.issue_id, 

320 success=True, # Closure is a valid outcome 

321 branch_name=branch_name, 

322 worktree_path=worktree_path, 

323 duration=time.time() - start_time, 

324 should_close=True, 

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

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

327 stdout=ready_result.stdout, 

328 stderr=ready_result.stderr, 

329 ) 

330 

331 # Handle BLOCKED verdict - issue has open dependencies 

332 if ready_parsed.get("is_blocked"): 

333 return WorkerResult( 

334 issue_id=issue.issue_id, 

335 success=False, 

336 was_blocked=True, 

337 branch_name=branch_name, 

338 worktree_path=worktree_path, 

339 duration=time.time() - start_time, 

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

341 stdout=ready_result.stdout, 

342 stderr=ready_result.stderr, 

343 ) 

344 

345 # Handle NOT_READY verdict 

346 if not ready_parsed["is_ready"]: 

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

348 if concerns: 

349 concern_msg = "; ".join(concerns) 

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

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

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

353 concern_msg = ( 

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

355 if raw_out 

356 else "No output from ready-issue" 

357 ) 

358 else: 

359 concern_msg = "Issue not ready" 

360 return WorkerResult( 

361 issue_id=issue.issue_id, 

362 success=False, 

363 branch_name=branch_name, 

364 worktree_path=worktree_path, 

365 duration=time.time() - start_time, 

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

367 stdout=ready_result.stdout, 

368 stderr=ready_result.stderr, 

369 ) 

370 

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

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

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

374 

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

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

377 

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

379 if issue.decision_needed is True: 

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

381 decide_result = self._run_claude_command( 

382 decide_cmd, worktree_path, issue_id=issue.issue_id 

383 ) 

384 if decide_result.returncode != 0: 

385 self.logger.warning( 

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

387 "continuing to implementation anyway..." 

388 ) 

389 

390 # Step 4: Get action from BRConfig 

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

392 

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

394 manage_cmd = self.parallel_config.get_manage_command( 

395 issue.issue_type, action, issue.issue_id 

396 ) 

397 manage_result = self._run_with_continuation( 

398 manage_cmd, 

399 worktree_path, 

400 issue_id=issue.issue_id, 

401 ) 

402 

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

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

405 

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

407 if issue.issue_id in self._terminated_during_shutdown: 

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

409 return WorkerResult( 

410 issue_id=issue.issue_id, 

411 success=False, 

412 interrupted=True, 

413 branch_name=branch_name, 

414 worktree_path=worktree_path, 

415 duration=time.time() - start_time, 

416 error="Interrupted during shutdown", 

417 stdout=manage_result.stdout, 

418 stderr=manage_result.stderr, 

419 ) 

420 

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

422 changed_files = self._get_changed_files(worktree_path) 

423 

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

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

426 if leaked_files: 

427 self.logger.warning( 

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

429 f"{leaked_files}" 

430 ) 

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

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

433 self._cleanup_leaked_files(leaked_files) 

434 

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

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

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

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

439 committed_leaks = self._detect_committed_leaks(baseline_head_sha) 

440 if committed_leaks: 

441 self.logger.warning( 

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

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

444 ) 

445 if not changed_files: 

446 recovered = self._recover_committed_leaks( 

447 committed_leaks, worktree_path, baseline_head_sha, issue.issue_id 

448 ) 

449 if recovered: 

450 changed_files = self._get_changed_files(worktree_path) 

451 

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

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

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

455 work_verified, verification_error = self._verify_work_was_done( 

456 changed_files, issue.issue_id, issue_filename 

457 ) 

458 

459 if manage_result.returncode != 0: 

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

461 return WorkerResult( 

462 issue_id=issue.issue_id, 

463 success=False, 

464 branch_name=branch_name, 

465 worktree_path=worktree_path, 

466 changed_files=changed_files, 

467 leaked_files=leaked_files, 

468 duration=time.time() - start_time, 

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

470 stdout=manage_result.stdout, 

471 stderr=manage_result.stderr, 

472 ) 

473 

474 if not work_verified: 

475 return WorkerResult( 

476 issue_id=issue.issue_id, 

477 success=False, 

478 branch_name=branch_name, 

479 worktree_path=worktree_path, 

480 changed_files=changed_files, 

481 leaked_files=leaked_files, 

482 duration=time.time() - start_time, 

483 error=verification_error, 

484 stdout=manage_result.stdout, 

485 stderr=manage_result.stderr, 

486 ) 

487 

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

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

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

491 

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

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

494 

495 if not base_updated: 

496 return WorkerResult( 

497 issue_id=issue.issue_id, 

498 success=False, 

499 branch_name=branch_name, 

500 worktree_path=worktree_path, 

501 changed_files=changed_files, 

502 leaked_files=leaked_files, 

503 duration=time.time() - start_time, 

504 error=base_error, 

505 stdout=manage_result.stdout, 

506 stderr=manage_result.stderr, 

507 ) 

508 

509 return WorkerResult( 

510 issue_id=issue.issue_id, 

511 success=True, 

512 branch_name=branch_name, 

513 worktree_path=worktree_path, 

514 changed_files=changed_files, 

515 leaked_files=leaked_files, 

516 duration=time.time() - start_time, 

517 error=None, 

518 stdout=manage_result.stdout, 

519 stderr=manage_result.stderr, 

520 was_corrected=was_corrected, 

521 corrections=corrections, 

522 ) 

523 

524 except Exception as e: 

525 return WorkerResult( 

526 issue_id=issue.issue_id, 

527 success=False, 

528 branch_name=branch_name, 

529 worktree_path=worktree_path, 

530 duration=time.time() - start_time, 

531 error=str(e), 

532 ) 

533 finally: 

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

535 with self._process_lock: 

536 self._active_worktrees.discard(worktree_path) 

537 

538 def _setup_worktree(self, worktree_path: Path, branch_name: str) -> None: 

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

540 

541 Args: 

542 worktree_path: Path for the new worktree 

543 branch_name: Name of the new branch 

544 """ 

545 from little_loops.worktree_utils import setup_worktree 

546 

547 setup_worktree( 

548 repo_path=self.repo_path, 

549 worktree_path=worktree_path, 

550 branch_name=branch_name, 

551 copy_files=self.parallel_config.worktree_copy_files, 

552 logger=self.logger, 

553 git_lock=self._git_lock, 

554 ) 

555 

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

557 if self.parallel_config.show_model: 

558 model = self._detect_worktree_model_via_api(worktree_path) 

559 if model: 

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

561 else: 

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

563 

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

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

566 

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

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

569 

570 Args: 

571 worktree_path: Path to the worktree to test 

572 

573 Returns: 

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

575 """ 

576 try: 

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

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

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

580 

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

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

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

584 env = os.environ.copy() 

585 env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1" 

586 env.update(invocation.env) 

587 

588 result = subprocess.run( 

589 [invocation.binary, *args], 

590 cwd=worktree_path, 

591 capture_output=True, 

592 text=True, 

593 timeout=30, 

594 env=env, 

595 ) 

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

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

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

599 # Return the first (primary) model from modelUsage 

600 if model_usage: 

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

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

603 pass 

604 return None 

605 

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

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

608 

609 Args: 

610 worktree_path: Path to the worktree to remove 

611 """ 

612 if not worktree_path.exists(): 

613 return 

614 

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

616 with self._process_lock: 

617 if worktree_path in self._active_worktrees: 

618 self.logger.warning( 

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

620 ) 

621 return 

622 

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

624 branch_result = subprocess.run( 

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

626 cwd=worktree_path, 

627 capture_output=True, 

628 text=True, 

629 ) 

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

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

632 

633 from little_loops.worktree_utils import cleanup_worktree 

634 

635 cleanup_worktree( 

636 worktree_path=worktree_path, 

637 repo_path=self.repo_path, 

638 logger=self.logger, 

639 git_lock=self._git_lock, 

640 delete_branch=delete_branch, 

641 ) 

642 

643 def _run_claude_command( 

644 self, 

645 command: str, 

646 working_dir: Path, 

647 issue_id: str | None = None, 

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

649 resume_session: bool = False, 

650 ) -> subprocess.CompletedProcess[str]: 

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

652 

653 Args: 

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

655 working_dir: Directory to run the command in 

656 issue_id: Optional issue ID for subprocess tracking 

657 on_usage: Optional usage callback for token tracking 

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

659 

660 Returns: 

661 CompletedProcess with stdout and stderr 

662 """ 

663 stream_output = self.parallel_config.stream_subprocess_output 

664 

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

666 if stream_output: 

667 if is_stderr: 

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

669 else: 

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

671 

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

673 if issue_id: 

674 with self._process_lock: 

675 self._active_processes[issue_id] = process 

676 

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

678 if issue_id: 

679 with self._process_lock: 

680 self._active_processes.pop(issue_id, None) 

681 

682 return _run_claude_base( 

683 command=command, 

684 timeout=self.parallel_config.timeout_per_issue, 

685 working_dir=working_dir, 

686 stream_callback=stream_callback if stream_output else None, 

687 on_process_start=on_start if issue_id else None, 

688 on_process_end=on_end if issue_id else None, 

689 idle_timeout=self.parallel_config.idle_timeout_per_issue, 

690 on_usage=on_usage, 

691 resume_session=resume_session, 

692 ) 

693 

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

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

696 

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

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

699 return success rather than triggering an unnecessary handoff cycle. 

700 

701 Args: 

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

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

704 

705 Returns: 

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

707 """ 

708 if issue_id is None: 

709 return False 

710 issues_dir = working_dir / ".issues" 

711 if not issues_dir.exists(): 

712 return False 

713 try: 

714 from little_loops.frontmatter import parse_frontmatter 

715 

716 # Search all category directories for the issue file 

717 for cat_dir in issues_dir.iterdir(): 

718 if not cat_dir.is_dir(): 

719 continue 

720 for f in cat_dir.iterdir(): 

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

722 continue 

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

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

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

726 return False 

727 except Exception: 

728 return False 

729 

730 def _run_with_continuation( 

731 self, 

732 command: str, 

733 working_dir: Path, 

734 issue_id: str | None = None, 

735 max_continuations: int = 3, 

736 context_limit: int = 200_000, 

737 sentinel_threshold: float = 0.60, 

738 guillotine_threshold: float = 0.90, 

739 run_dir: str | None = None, 

740 sprint_context: SprintWorkerContext | None = None, 

741 ) -> subprocess.CompletedProcess[str]: 

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

743 

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

745 

746 Args: 

747 command: The command to run 

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

749 issue_id: Optional issue ID for subprocess tracking 

750 max_continuations: Maximum number of continuation attempts 

751 context_limit: Context window size in tokens 

752 sentinel_threshold: Write sentinel when usage >= this fraction 

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

754 

755 Returns: 

756 Combined CompletedProcess with all session outputs 

757 """ 

758 all_stdout: list[str] = [] 

759 all_stderr: list[str] = [] 

760 current_command = command 

761 continuation_count = 0 

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

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

764 ) 

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

766 

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

768 _last_input: list[int] = [0] 

769 _last_output: list[int] = [0] 

770 

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

772 _last_input[0] = input_tokens 

773 _last_output[0] = output_tokens 

774 

775 while continuation_count <= max_continuations: 

776 result = self._run_claude_command( 

777 current_command, 

778 working_dir, 

779 issue_id=issue_id, 

780 on_usage=_usage_tracker, 

781 ) 

782 

783 all_stdout.append(result.stdout) 

784 all_stderr.append(result.stderr) 

785 

786 # Standard path: Claude emitted CONTEXT_HANDOFF 

787 if detect_context_handoff(result.stdout): 

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

789 

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

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

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

793 already_done = self._check_issue_already_done(issue_id, working_dir) 

794 if already_done: 

795 self.logger.info( 

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

797 "skipping handoff and returning success" 

798 ) 

799 result = subprocess.CompletedProcess( 

800 args=result.args, 

801 returncode=0, 

802 stdout=result.stdout, 

803 stderr=result.stderr, 

804 ) 

805 break 

806 

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

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

809 handoff_message = "CONTEXT_HANDOFF: Ready for fresh session" 

810 print(handoff_message) 

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

812 

813 result = subprocess.CompletedProcess( 

814 args=result.args, 

815 returncode=0, 

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

817 stderr=result.stderr, 

818 ) 

819 break 

820 

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

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

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

824 

825 # Option J: guillotine — fresh session. 

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

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

828 if ( 

829 prompt_too_long or usage_ratio >= guillotine_threshold 

830 ) and continuation_count < max_continuations: 

831 trigger_reason = ( 

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

833 ) 

834 self.logger.warning( 

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

836 ) 

837 if run_dir is not None: 

838 try: 

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

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

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

842 sprint_framing = "" 

843 if sprint_context is not None: 

844 sprint_framing = ( 

845 f"## Sprint Worker Context\n" 

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

847 f"{sprint_context.issue_id}\n" 

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

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

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

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

852 ) 

853 guillotine_file.write_text( 

854 sprint_framing + f"## Intent\n" 

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

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

857 f"Trigger reason: {trigger_reason} " 

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

859 f"\n" 

860 f"## Next Steps\n" 

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

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

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

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

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

866 encoding="utf-8", 

867 ) 

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

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

870 except Exception as exc: 

871 self.logger.warning( 

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

873 "falling back to summary blob" 

874 ) 

875 guillotine_cmd = command 

876 else: 

877 try: 

878 guillotine_cmd = assemble_guillotine_prompt( 

879 original_command=command, 

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

881 token_stats={ 

882 "input_tokens": _last_input[0], 

883 "output_tokens": _last_output[0], 

884 "context_limit": context_limit, 

885 "trigger_reason": trigger_reason, 

886 }, 

887 sprint_context=sprint_context, 

888 ) 

889 except Exception as exc: 

890 self.logger.warning( 

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

892 "using bare restart" 

893 ) 

894 guillotine_cmd = command 

895 continuation_count += 1 

896 current_command = guillotine_cmd 

897 _last_input[0] = 0 

898 _last_output[0] = 0 

899 continue 

900 

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

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

903 sentinel_data = read_sentinel(working_dir) 

904 if sentinel_data is not None and continuation_count < max_continuations: 

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

906 self.logger.info( 

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

908 "sending explicit handoff instruction" 

909 ) 

910 continuation_count += 1 

911 explicit_handoff_instruction = ( 

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

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

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

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

916 ) 

917 _last_input[0] = 0 

918 _last_output[0] = 0 

919 result = self._run_claude_command( 

920 explicit_handoff_instruction, 

921 working_dir, 

922 issue_id=issue_id, 

923 on_usage=_usage_tracker, 

924 resume_session=True, 

925 ) 

926 all_stdout.append(result.stdout) 

927 all_stderr.append(result.stderr) 

928 

929 if detect_context_handoff(result.stdout): 

930 self.logger.info( 

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

932 ) 

933 prompt_content = read_continuation_prompt(working_dir) 

934 if prompt_content and continuation_count < max_continuations: 

935 continuation_count += 1 

936 self.logger.info( 

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

938 ) 

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

940 _last_input[0] = 0 

941 _last_output[0] = 0 

942 continue 

943 break 

944 

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

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

947 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

948 self.logger.info( 

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

950 ) 

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

952 

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

954 break 

955 

956 return subprocess.CompletedProcess( 

957 args=result.args, 

958 returncode=result.returncode, 

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

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

961 ) 

962 

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

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

965 

966 Args: 

967 worktree_path: Path to the worktree 

968 

969 Returns: 

970 List of changed file paths relative to repo root 

971 """ 

972 result = subprocess.run( 

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

974 cwd=worktree_path, 

975 capture_output=True, 

976 text=True, 

977 timeout=30, 

978 ) 

979 

980 if result.returncode != 0: 

981 return [] 

982 

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

984 

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

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

987 

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

989 merge coordination, preventing conflicts when main advances during 

990 sprint execution (BUG-180). 

991 

992 Args: 

993 worktree_path: Path to the worker's worktree 

994 issue_id: Issue ID for logging 

995 

996 Returns: 

997 Tuple of (success, error_message) 

998 """ 

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

1000 base = self.parallel_config.base_branch 

1001 remote = self.parallel_config.remote_name 

1002 fetch_result = subprocess.run( 

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

1004 cwd=worktree_path, 

1005 capture_output=True, 

1006 text=True, 

1007 timeout=60, 

1008 ) 

1009 

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

1011 

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

1013 rebase_result = subprocess.run( 

1014 ["git", "rebase", rebase_target], 

1015 cwd=worktree_path, 

1016 capture_output=True, 

1017 text=True, 

1018 timeout=120, 

1019 ) 

1020 

1021 if rebase_result.returncode != 0: 

1022 # Abort the failed rebase 

1023 subprocess.run( 

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

1025 cwd=worktree_path, 

1026 capture_output=True, 

1027 timeout=10, 

1028 ) 

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

1030 

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

1032 return True, "" 

1033 

1034 def _verify_work_was_done( 

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

1036 ) -> tuple[bool, str]: 

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

1038 

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

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

1041 

1042 Args: 

1043 changed_files: List of files changed during processing 

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

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

1046 

1047 Returns: 

1048 Tuple of (success, error_message) 

1049 """ 

1050 if not changed_files: 

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

1052 

1053 # Check if code changes are required 

1054 if not self.parallel_config.require_code_changes: 

1055 return True, "" 

1056 

1057 # Use shared verification function 

1058 if verify_work_was_done(self.logger, changed_files): 

1059 return True, "" 

1060 

1061 # Generate descriptive error with actual excluded files 

1062 excluded_files = [ 

1063 f 

1064 for f in changed_files 

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

1066 ] 

1067 if excluded_files: 

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

1069 if len(excluded_files) > 5: 

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

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

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

1073 

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

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

1076 

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

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

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

1080 

1081 Args: 

1082 file_lower: Lowercase file path to check 

1083 current_issue_id_lower: Lowercase issue ID of the current worker 

1084 

1085 Returns: 

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

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

1088 """ 

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

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

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

1092 

1093 if not matches: 

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

1095 return False 

1096 

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

1098 for match in matches: 

1099 if match == current_issue_id_lower: 

1100 return False # File belongs to current worker 

1101 

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

1103 return True 

1104 

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

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

1107 

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

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

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

1111 and after worker execution. 

1112 

1113 Args: 

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

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

1116 

1117 Returns: 

1118 List of file paths that were leaked to main repo 

1119 """ 

1120 # Get current status of main repo 

1121 result = self._git_lock.run( 

1122 ["status", "--porcelain"], 

1123 cwd=self.repo_path, 

1124 timeout=30, 

1125 ) 

1126 

1127 if result.returncode != 0: 

1128 return [] 

1129 

1130 current_files: set[str] = set() 

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

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

1133 continue 

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

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

1136 # Handle renamed files (old -> new) 

1137 if " -> " in file_path: 

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

1139 current_files.add(file_path) 

1140 

1141 # Find new files that appeared during worker execution 

1142 new_files = current_files - baseline_status 

1143 

1144 # Filter to files likely related to this issue 

1145 issue_id_lower = issue_id.lower() 

1146 leaked_files: list[str] = [] 

1147 

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

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

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

1151 if dir_path: 

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

1153 if normalized not in source_prefixes: 

1154 source_prefixes.append(normalized) 

1155 

1156 for file_path in new_files: 

1157 # Skip state file (managed by orchestrator) 

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

1159 continue 

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

1161 if file_path == ".gitignore": 

1162 continue 

1163 

1164 # Check if file is related to this issue 

1165 file_lower = file_path.lower() 

1166 if issue_id_lower in file_lower: 

1167 leaked_files.append(file_path) 

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

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

1170 leaked_files.append(file_path) 

1171 # Catch thoughts/plans files 

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

1173 leaked_files.append(file_path) 

1174 # Catch issue files in any issue directory variant 

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

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

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

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

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

1180 leaked_files.append(file_path) 

1181 

1182 return leaked_files 

1183 

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

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

1186 

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

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

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

1190 safely discard these leaked changes from the main repo. 

1191 

1192 Args: 

1193 leaked_files: List of file paths leaked to main repo 

1194 

1195 Returns: 

1196 Number of files successfully cleaned up 

1197 """ 

1198 if not leaked_files: 

1199 return 0 

1200 

1201 cleaned = 0 

1202 

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

1204 status_result = self._git_lock.run( 

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

1206 cwd=self.repo_path, 

1207 timeout=30, 

1208 ) 

1209 

1210 tracked_files: list[str] = [] 

1211 untracked_files: list[str] = [] 

1212 

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

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

1215 continue 

1216 status_code = line[:2] 

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

1218 

1219 if status_code.startswith("?"): 

1220 # Untracked file - need to delete 

1221 untracked_files.append(file_path) 

1222 else: 

1223 # Tracked file - can use git checkout to discard 

1224 tracked_files.append(file_path) 

1225 

1226 # Discard changes to tracked files 

1227 if tracked_files: 

1228 checkout_result = self._git_lock.run( 

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

1230 cwd=self.repo_path, 

1231 timeout=30, 

1232 ) 

1233 if checkout_result.returncode == 0: 

1234 cleaned += len(tracked_files) 

1235 else: 

1236 self.logger.warning( 

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

1238 ) 

1239 

1240 # Delete untracked files 

1241 for file_path in untracked_files: 

1242 full_path = self.repo_path / file_path 

1243 try: 

1244 if full_path.exists(): 

1245 full_path.unlink() 

1246 cleaned += 1 

1247 except OSError as e: 

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

1249 

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

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

1252 accounted_files = set(tracked_files + untracked_files) 

1253 for file_path in leaked_files: 

1254 if file_path not in accounted_files: 

1255 full_path = self.repo_path / file_path 

1256 if full_path.exists(): 

1257 try: 

1258 full_path.unlink() 

1259 cleaned += 1 

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

1261 except OSError as e: 

1262 self.logger.warning( 

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

1264 ) 

1265 else: 

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

1267 

1268 if cleaned > 0: 

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

1270 

1271 return cleaned 

1272 

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

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

1275 

1276 Returns: 

1277 Set of file paths currently showing in git status 

1278 """ 

1279 result = self._git_lock.run( 

1280 ["status", "--porcelain"], 

1281 cwd=self.repo_path, 

1282 timeout=30, 

1283 ) 

1284 

1285 if result.returncode != 0: 

1286 return set() 

1287 

1288 files: set[str] = set() 

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

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

1291 continue 

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

1293 if " -> " in file_path: 

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

1295 files.add(file_path) 

1296 

1297 return files 

1298 

1299 def _get_main_head_sha(self) -> str: 

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

1301 

1302 Returns: 

1303 HEAD SHA string, or empty string if unavailable 

1304 """ 

1305 result = self._git_lock.run( 

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

1307 cwd=self.repo_path, 

1308 timeout=10, 

1309 ) 

1310 if result.returncode == 0: 

1311 return result.stdout.strip() 

1312 return "" 

1313 

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

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

1316 

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

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

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

1320 before and after worker execution. 

1321 

1322 Args: 

1323 baseline_head_sha: HEAD SHA captured before worker started 

1324 

1325 Returns: 

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

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

1328 """ 

1329 if not baseline_head_sha: 

1330 return [] 

1331 

1332 current_sha = self._get_main_head_sha() 

1333 if not current_sha or current_sha == baseline_head_sha: 

1334 return [] 

1335 

1336 # Get list of new commits on main since baseline 

1337 result = self._git_lock.run( 

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

1339 cwd=self.repo_path, 

1340 timeout=30, 

1341 ) 

1342 if result.returncode != 0: 

1343 return [] 

1344 

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

1346 return commits 

1347 

1348 def _recover_committed_leaks( 

1349 self, 

1350 leaked_commits: list[str], 

1351 worktree_path: Path, 

1352 baseline_head_sha: str, 

1353 issue_id: str, 

1354 ) -> bool: 

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

1356 

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

1358 we attempt to: 

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

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

1361 

1362 This preserves the implementation work in the worktree while 

1363 cleaning up the incorrect commits on main. 

1364 

1365 Args: 

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

1367 worktree_path: Path to the worker's worktree 

1368 baseline_head_sha: Main HEAD SHA before worker started 

1369 issue_id: Issue ID for logging 

1370 

1371 Returns: 

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

1373 not required for a True return value) 

1374 """ 

1375 self.logger.info( 

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

1377 f"commit(s) to worktree" 

1378 ) 

1379 

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

1381 for sha in reversed(leaked_commits): 

1382 result = subprocess.run( 

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

1384 cwd=worktree_path, 

1385 capture_output=True, 

1386 text=True, 

1387 timeout=60, 

1388 ) 

1389 if result.returncode != 0: 

1390 subprocess.run( 

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

1392 cwd=worktree_path, 

1393 capture_output=True, 

1394 timeout=10, 

1395 ) 

1396 self.logger.warning( 

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

1398 ) 

1399 return False 

1400 

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

1402 current_main_sha = self._get_main_head_sha() 

1403 most_recent_leaked = leaked_commits[0] # Newest first 

1404 if current_main_sha == most_recent_leaked: 

1405 reset_result = self._git_lock.run( 

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

1407 cwd=self.repo_path, 

1408 timeout=30, 

1409 ) 

1410 if reset_result.returncode == 0: 

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

1412 else: 

1413 self.logger.warning( 

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

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

1416 ) 

1417 else: 

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

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

1420 self.logger.info( 

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

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

1423 f"attempting surgical rebase to excise leaked commits" 

1424 ) 

1425 rebase_result = self._git_lock.run( 

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

1427 cwd=self.repo_path, 

1428 timeout=60, 

1429 ) 

1430 if rebase_result.returncode == 0: 

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

1432 else: 

1433 self._git_lock.run( 

1434 ["rebase", "--abort"], 

1435 cwd=self.repo_path, 

1436 timeout=10, 

1437 ) 

1438 self.logger.warning( 

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

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

1441 ) 

1442 

1443 self.logger.info( 

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

1445 f"cherry-picked to worktree branch" 

1446 ) 

1447 return True 

1448 

1449 @property 

1450 def active_count(self) -> int: 

1451 """Number of currently active workers. 

1452 

1453 Includes both workers with running futures AND workers whose futures 

1454 are done but callbacks haven't completed yet. 

1455 """ 

1456 with self._process_lock: 

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

1458 with self._callback_lock: 

1459 pending_callback_count = len(self._pending_callbacks) 

1460 return running_futures + pending_callback_count 

1461 

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

1463 """Update the stage of a worker. 

1464 

1465 Args: 

1466 issue_id: Issue ID being processed 

1467 stage: New stage value 

1468 """ 

1469 with self._process_lock: 

1470 self._worker_stages[issue_id] = stage 

1471 

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

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

1474 

1475 Args: 

1476 issue_id: Issue ID being processed 

1477 

1478 Returns: 

1479 Current stage, or None if issue not being tracked 

1480 """ 

1481 with self._process_lock: 

1482 return self._worker_stages.get(issue_id) 

1483 

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

1485 """Get all active worker stages. 

1486 

1487 Returns: 

1488 Dictionary mapping issue_id to current stage for active workers 

1489 """ 

1490 with self._process_lock: 

1491 # Only return workers that are actually active 

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

1493 return { 

1494 issue_id: stage 

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

1496 if issue_id in active_ids 

1497 } 

1498 

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

1500 """Remove a worker from stage tracking. 

1501 

1502 Args: 

1503 issue_id: Issue ID to remove 

1504 """ 

1505 with self._process_lock: 

1506 self._worker_stages.pop(issue_id, None) 

1507 

1508 def cleanup_all_worktrees(self) -> None: 

1509 """Clean up all worker worktrees.""" 

1510 worktree_base = self.repo_path / self.parallel_config.worktree_base 

1511 if not worktree_base.exists(): 

1512 return 

1513 

1514 from little_loops.worktree_utils import _is_ll_worktree 

1515 

1516 for worktree_dir in worktree_base.iterdir(): 

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

1518 self._cleanup_worktree(worktree_dir) 

1519 

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