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

503 statements  

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

23from little_loops.parallel.git_lock import GitLock 

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

25from little_loops.subprocess_utils import ( 

26 assemble_guillotine_prompt, 

27 detect_context_handoff, 

28 read_continuation_prompt, 

29 read_sentinel, 

30 write_sentinel, 

31) 

32from little_loops.subprocess_utils import ( 

33 run_claude_command as _run_claude_base, 

34) 

35from little_loops.work_verification import EXCLUDED_DIRECTORIES, verify_work_was_done 

36 

37if TYPE_CHECKING: 

38 from little_loops.config import BRConfig 

39 from little_loops.issue_parser import IssueInfo 

40 from little_loops.logger import Logger 

41 

42 

43class WorkerPool: 

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

45 

46 Each worker: 

47 1. Creates a dedicated git worktree and branch 

48 2. Runs issue validation and implementation via Claude CLI 

49 3. Commits changes locally 

50 4. Returns results for merge coordination 

51 

52 Example: 

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

54 >>> pool.start() 

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

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

57 >>> pool.shutdown() 

58 """ 

59 

60 def __init__( 

61 self, 

62 parallel_config: ParallelConfig, 

63 br_config: BRConfig, 

64 logger: Logger, 

65 repo_path: Path | None = None, 

66 git_lock: GitLock | None = None, 

67 ) -> None: 

68 """Initialize the worker pool. 

69 

70 Args: 

71 parallel_config: Parallel processing configuration 

72 br_config: Project configuration (for category actions) 

73 logger: Logger for worker output 

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

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

76 """ 

77 self.parallel_config = parallel_config 

78 self.br_config = br_config 

79 self.logger = logger 

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

81 self._git_lock = git_lock or GitLock(logger) 

82 self._executor: ThreadPoolExecutor | None = None 

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

84 # Track active subprocesses for forceful termination on shutdown 

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

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

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

88 self._process_lock = threading.Lock() 

89 # Track callbacks currently executing 

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

91 self._callback_lock = threading.Lock() 

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

93 self._shutdown_requested = False 

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

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

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

97 

98 def start(self) -> None: 

99 """Start the worker pool.""" 

100 if self._executor is not None: 

101 return 

102 

103 # Ensure worktree base directory exists 

104 worktree_base = self.repo_path / self.parallel_config.worktree_base 

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

106 

107 self._executor = ThreadPoolExecutor( 

108 max_workers=self.parallel_config.max_workers, 

109 thread_name_prefix="issue-worker", 

110 ) 

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

112 

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

114 """Shutdown the worker pool. 

115 

116 Args: 

117 wait: Whether to wait for pending tasks to complete 

118 """ 

119 if self._executor is None: 

120 return 

121 

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

123 

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

125 if not wait: 

126 self.terminate_all_processes() 

127 

128 self._executor.shutdown(wait=wait) 

129 self._executor = None 

130 

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

132 """Set the shutdown flag. 

133 

134 Called by orchestrator during shutdown to enable tracking of 

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

136 """ 

137 self._shutdown_requested = value 

138 

139 def terminate_all_processes(self) -> None: 

140 """Forcefully terminate all active subprocesses. 

141 

142 Called when we need to abort workers immediately, 

143 such as on timeout or shutdown. 

144 """ 

145 with self._process_lock: 

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

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

148 self.logger.warning( 

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

150 ) 

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

152 if self._shutdown_requested: 

153 self._terminated_during_shutdown.add(issue_id) 

154 try: 

155 # Send SIGTERM first for graceful termination 

156 process.terminate() 

157 try: 

158 process.wait(timeout=5) 

159 except subprocess.TimeoutExpired: 

160 # Force kill if SIGTERM didn't work 

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

162 process.kill() 

163 process.wait(timeout=2) 

164 except Exception as e: 

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

166 self._active_processes.clear() 

167 

168 def submit( 

169 self, 

170 issue: IssueInfo, 

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

172 ) -> Future[WorkerResult]: 

173 """Submit an issue for processing. 

174 

175 Args: 

176 issue: Issue to process 

177 on_complete: Optional callback when processing completes 

178 

179 Returns: 

180 Future that will contain the WorkerResult 

181 """ 

182 if self._executor is None: 

183 raise RuntimeError("Worker pool not started") 

184 

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

186 with self._process_lock: 

187 self._active_workers[issue.issue_id] = future 

188 

189 if on_complete: 

190 future.add_done_callback( 

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

192 ) 

193 

194 return future 

195 

196 def _handle_completion( 

197 self, 

198 future: Future[WorkerResult], 

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

200 issue_id: str, 

201 ) -> None: 

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

203 with self._callback_lock: 

204 self._pending_callbacks.add(issue_id) 

205 try: 

206 try: 

207 result = future.result() 

208 except Exception as e: 

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

210 result = WorkerResult( 

211 issue_id=issue_id, 

212 success=False, 

213 branch_name="", 

214 worktree_path=Path(), 

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

216 ) 

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

218 if result.success: 

219 self.set_worker_stage(issue_id, WorkerStage.COMPLETED) 

220 elif result.interrupted: 

221 self.set_worker_stage(issue_id, WorkerStage.INTERRUPTED) 

222 else: 

223 self.set_worker_stage(issue_id, WorkerStage.FAILED) 

224 try: 

225 callback(result) 

226 except Exception as e: 

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

228 finally: 

229 with self._callback_lock: 

230 self._pending_callbacks.discard(issue_id) 

231 

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

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

234 

235 Args: 

236 issue: Issue to process 

237 

238 Returns: 

239 WorkerResult with processing outcome 

240 """ 

241 start_time = time.time() 

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

243 if self.parallel_config.use_feature_branches: 

244 from little_loops.issue_parser import slugify 

245 

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

247 else: 

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

249 worktree_path = ( 

250 self.repo_path 

251 / self.parallel_config.worktree_base 

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

253 ) 

254 

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

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

257 

258 # Capture baseline of main repo status before worker starts 

259 # Used to detect files incorrectly written to main repo 

260 baseline_status = self._get_main_repo_baseline() 

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

262 baseline_head_sha = self._get_main_head_sha() 

263 

264 try: 

265 # Step 1: Create worktree with new branch 

266 self._setup_worktree(worktree_path, branch_name) 

267 

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

269 with self._process_lock: 

270 self._active_worktrees.add(worktree_path) 

271 

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

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

274 

275 # Step 2: Run ready-issue validation 

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

277 ready_result = self._run_claude_command( 

278 ready_cmd, 

279 worktree_path, 

280 issue_id=issue.issue_id, 

281 ) 

282 

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

284 if issue.issue_id in self._terminated_during_shutdown: 

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

286 return WorkerResult( 

287 issue_id=issue.issue_id, 

288 success=False, 

289 interrupted=True, 

290 branch_name=branch_name, 

291 worktree_path=worktree_path, 

292 duration=time.time() - start_time, 

293 error="Interrupted during shutdown", 

294 stdout=ready_result.stdout, 

295 stderr=ready_result.stderr, 

296 ) 

297 

298 if ready_result.returncode != 0: 

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

300 return WorkerResult( 

301 issue_id=issue.issue_id, 

302 success=False, 

303 branch_name=branch_name, 

304 worktree_path=worktree_path, 

305 duration=time.time() - start_time, 

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

307 stdout=ready_result.stdout, 

308 stderr=ready_result.stderr, 

309 ) 

310 

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

312 ready_parsed = parse_ready_issue_output(ready_result.stdout) 

313 

314 # Handle CLOSE verdict - issue should not be implemented 

315 if ready_parsed.get("should_close"): 

316 return WorkerResult( 

317 issue_id=issue.issue_id, 

318 success=True, # Closure is a valid outcome 

319 branch_name=branch_name, 

320 worktree_path=worktree_path, 

321 duration=time.time() - start_time, 

322 should_close=True, 

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

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

325 stdout=ready_result.stdout, 

326 stderr=ready_result.stderr, 

327 ) 

328 

329 # Handle BLOCKED verdict - issue has open dependencies 

330 if ready_parsed.get("is_blocked"): 

331 return WorkerResult( 

332 issue_id=issue.issue_id, 

333 success=False, 

334 was_blocked=True, 

335 branch_name=branch_name, 

336 worktree_path=worktree_path, 

337 duration=time.time() - start_time, 

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

339 stdout=ready_result.stdout, 

340 stderr=ready_result.stderr, 

341 ) 

342 

343 # Handle NOT_READY verdict 

344 if not ready_parsed["is_ready"]: 

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

346 if concerns: 

347 concern_msg = "; ".join(concerns) 

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

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

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

351 concern_msg = ( 

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

353 if raw_out 

354 else "No output from ready-issue" 

355 ) 

356 else: 

357 concern_msg = "Issue not ready" 

358 return WorkerResult( 

359 issue_id=issue.issue_id, 

360 success=False, 

361 branch_name=branch_name, 

362 worktree_path=worktree_path, 

363 duration=time.time() - start_time, 

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

365 stdout=ready_result.stdout, 

366 stderr=ready_result.stderr, 

367 ) 

368 

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

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

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

372 

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

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

375 

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

377 if issue.decision_needed is True: 

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

379 decide_result = self._run_claude_command( 

380 decide_cmd, worktree_path, issue_id=issue.issue_id 

381 ) 

382 if decide_result.returncode != 0: 

383 self.logger.warning( 

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

385 "continuing to implementation anyway..." 

386 ) 

387 

388 # Step 4: Get action from BRConfig 

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

390 

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

392 manage_cmd = self.parallel_config.get_manage_command( 

393 issue.issue_type, action, issue.issue_id 

394 ) 

395 manage_result = self._run_with_continuation( 

396 manage_cmd, 

397 worktree_path, 

398 issue_id=issue.issue_id, 

399 ) 

400 

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

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

403 

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

405 if issue.issue_id in self._terminated_during_shutdown: 

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

407 return WorkerResult( 

408 issue_id=issue.issue_id, 

409 success=False, 

410 interrupted=True, 

411 branch_name=branch_name, 

412 worktree_path=worktree_path, 

413 duration=time.time() - start_time, 

414 error="Interrupted during shutdown", 

415 stdout=manage_result.stdout, 

416 stderr=manage_result.stderr, 

417 ) 

418 

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

420 changed_files = self._get_changed_files(worktree_path) 

421 

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

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

424 if leaked_files: 

425 self.logger.warning( 

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

427 f"{leaked_files}" 

428 ) 

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

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

431 self._cleanup_leaked_files(leaked_files) 

432 

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

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

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

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

437 committed_leaks = self._detect_committed_leaks(baseline_head_sha) 

438 if committed_leaks: 

439 self.logger.warning( 

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

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

442 ) 

443 if not changed_files: 

444 recovered = self._recover_committed_leaks( 

445 committed_leaks, worktree_path, baseline_head_sha, issue.issue_id 

446 ) 

447 if recovered: 

448 changed_files = self._get_changed_files(worktree_path) 

449 

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

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

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

453 work_verified, verification_error = self._verify_work_was_done( 

454 changed_files, issue.issue_id, issue_filename 

455 ) 

456 

457 if manage_result.returncode != 0: 

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

459 return WorkerResult( 

460 issue_id=issue.issue_id, 

461 success=False, 

462 branch_name=branch_name, 

463 worktree_path=worktree_path, 

464 changed_files=changed_files, 

465 leaked_files=leaked_files, 

466 duration=time.time() - start_time, 

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

468 stdout=manage_result.stdout, 

469 stderr=manage_result.stderr, 

470 ) 

471 

472 if not work_verified: 

473 return WorkerResult( 

474 issue_id=issue.issue_id, 

475 success=False, 

476 branch_name=branch_name, 

477 worktree_path=worktree_path, 

478 changed_files=changed_files, 

479 leaked_files=leaked_files, 

480 duration=time.time() - start_time, 

481 error=verification_error, 

482 stdout=manage_result.stdout, 

483 stderr=manage_result.stderr, 

484 ) 

485 

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

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

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

489 

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

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

492 

493 if not base_updated: 

494 return WorkerResult( 

495 issue_id=issue.issue_id, 

496 success=False, 

497 branch_name=branch_name, 

498 worktree_path=worktree_path, 

499 changed_files=changed_files, 

500 leaked_files=leaked_files, 

501 duration=time.time() - start_time, 

502 error=base_error, 

503 stdout=manage_result.stdout, 

504 stderr=manage_result.stderr, 

505 ) 

506 

507 return WorkerResult( 

508 issue_id=issue.issue_id, 

509 success=True, 

510 branch_name=branch_name, 

511 worktree_path=worktree_path, 

512 changed_files=changed_files, 

513 leaked_files=leaked_files, 

514 duration=time.time() - start_time, 

515 error=None, 

516 stdout=manage_result.stdout, 

517 stderr=manage_result.stderr, 

518 was_corrected=was_corrected, 

519 corrections=corrections, 

520 ) 

521 

522 except Exception as e: 

523 return WorkerResult( 

524 issue_id=issue.issue_id, 

525 success=False, 

526 branch_name=branch_name, 

527 worktree_path=worktree_path, 

528 duration=time.time() - start_time, 

529 error=str(e), 

530 ) 

531 finally: 

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

533 with self._process_lock: 

534 self._active_worktrees.discard(worktree_path) 

535 

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

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

538 

539 Args: 

540 worktree_path: Path for the new worktree 

541 branch_name: Name of the new branch 

542 """ 

543 from little_loops.worktree_utils import setup_worktree 

544 

545 setup_worktree( 

546 repo_path=self.repo_path, 

547 worktree_path=worktree_path, 

548 branch_name=branch_name, 

549 copy_files=self.parallel_config.worktree_copy_files, 

550 logger=self.logger, 

551 git_lock=self._git_lock, 

552 ) 

553 

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

555 if self.parallel_config.show_model: 

556 model = self._detect_worktree_model_via_api(worktree_path) 

557 if model: 

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

559 else: 

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

561 

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

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

564 

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

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

567 

568 Args: 

569 worktree_path: Path to the worktree to test 

570 

571 Returns: 

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

573 """ 

574 try: 

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

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

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

578 env = os.environ.copy() 

579 env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1" 

580 

581 # Use a minimal prompt that requires an API call to get modelUsage 

582 result = subprocess.run( 

583 [ 

584 "claude", 

585 "-p", 

586 "reply with just 'ok'", 

587 "--output-format", 

588 "json", 

589 ], 

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 _run_with_continuation( 

695 self, 

696 command: str, 

697 working_dir: Path, 

698 issue_id: str | None = None, 

699 max_continuations: int = 3, 

700 context_limit: int = 200_000, 

701 sentinel_threshold: float = 0.60, 

702 guillotine_threshold: float = 0.90, 

703 ) -> subprocess.CompletedProcess[str]: 

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

705 

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

707 

708 Args: 

709 command: The command to run 

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

711 issue_id: Optional issue ID for subprocess tracking 

712 max_continuations: Maximum number of continuation attempts 

713 context_limit: Context window size in tokens 

714 sentinel_threshold: Write sentinel when usage >= this fraction 

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

716 

717 Returns: 

718 Combined CompletedProcess with all session outputs 

719 """ 

720 all_stdout: list[str] = [] 

721 all_stderr: list[str] = [] 

722 current_command = command 

723 continuation_count = 0 

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

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

726 ) 

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

728 

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

730 _last_input: list[int] = [0] 

731 _last_output: list[int] = [0] 

732 

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

734 _last_input[0] = input_tokens 

735 _last_output[0] = output_tokens 

736 

737 while continuation_count <= max_continuations: 

738 result = self._run_claude_command( 

739 current_command, 

740 working_dir, 

741 issue_id=issue_id, 

742 on_usage=_usage_tracker, 

743 ) 

744 

745 all_stdout.append(result.stdout) 

746 all_stderr.append(result.stderr) 

747 

748 # Standard path: Claude emitted CONTEXT_HANDOFF 

749 if detect_context_handoff(result.stdout): 

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

751 

752 prompt_content = read_continuation_prompt(working_dir) 

753 if not prompt_content: 

754 self.logger.warning( 

755 f"{tag} Context handoff signaled but no continuation prompt found" 

756 ) 

757 all_stderr.append("Handoff detected but no continuation prompt found") 

758 result = subprocess.CompletedProcess( 

759 args=result.args, returncode=1, stdout=result.stdout, stderr=result.stderr 

760 ) 

761 break 

762 

763 if continuation_count >= max_continuations: 

764 self.logger.warning( 

765 f"{tag} Reached max continuations ({max_continuations}), stopping" 

766 ) 

767 break 

768 

769 continuation_count += 1 

770 self.logger.info(f"{tag} Starting continuation session #{continuation_count}") 

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

772 continue 

773 

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

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

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

777 

778 # Option J: guillotine — fresh session with transcript-summary prompt 

779 if ( 

780 prompt_too_long or usage_ratio >= guillotine_threshold 

781 ) and continuation_count < max_continuations: 

782 trigger_reason = ( 

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

784 ) 

785 self.logger.warning( 

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

787 ) 

788 try: 

789 guillotine_cmd = assemble_guillotine_prompt( 

790 original_command=command, 

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

792 token_stats={ 

793 "input_tokens": _last_input[0], 

794 "output_tokens": _last_output[0], 

795 "context_limit": context_limit, 

796 "trigger_reason": trigger_reason, 

797 }, 

798 ) 

799 except Exception as exc: 

800 self.logger.warning( 

801 f"{tag} Failed to assemble guillotine prompt ({exc}), using bare restart" 

802 ) 

803 guillotine_cmd = command 

804 continuation_count += 1 

805 current_command = guillotine_cmd 

806 _last_input[0] = 0 

807 _last_output[0] = 0 

808 continue 

809 

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

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

812 sentinel_data = read_sentinel(working_dir) 

813 if sentinel_data is not None and continuation_count < max_continuations: 

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

815 self.logger.info( 

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

817 "sending explicit handoff instruction" 

818 ) 

819 continuation_count += 1 

820 explicit_handoff_instruction = ( 

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

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

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

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

825 ) 

826 _last_input[0] = 0 

827 _last_output[0] = 0 

828 result = self._run_claude_command( 

829 explicit_handoff_instruction, 

830 working_dir, 

831 issue_id=issue_id, 

832 on_usage=_usage_tracker, 

833 resume_session=True, 

834 ) 

835 all_stdout.append(result.stdout) 

836 all_stderr.append(result.stderr) 

837 

838 if detect_context_handoff(result.stdout): 

839 self.logger.info( 

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

841 ) 

842 prompt_content = read_continuation_prompt(working_dir) 

843 if prompt_content and continuation_count < max_continuations: 

844 continuation_count += 1 

845 self.logger.info( 

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

847 ) 

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

849 _last_input[0] = 0 

850 _last_output[0] = 0 

851 continue 

852 break 

853 

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

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

856 if total_tokens > 0 and usage_ratio >= sentinel_threshold: 

857 self.logger.info( 

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

859 ) 

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

861 

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

863 break 

864 

865 return subprocess.CompletedProcess( 

866 args=result.args, 

867 returncode=result.returncode, 

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

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

870 ) 

871 

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

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

874 

875 Args: 

876 worktree_path: Path to the worktree 

877 

878 Returns: 

879 List of changed file paths relative to repo root 

880 """ 

881 result = subprocess.run( 

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

883 cwd=worktree_path, 

884 capture_output=True, 

885 text=True, 

886 timeout=30, 

887 ) 

888 

889 if result.returncode != 0: 

890 return [] 

891 

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

893 

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

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

896 

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

898 merge coordination, preventing conflicts when main advances during 

899 sprint execution (BUG-180). 

900 

901 Args: 

902 worktree_path: Path to the worker's worktree 

903 issue_id: Issue ID for logging 

904 

905 Returns: 

906 Tuple of (success, error_message) 

907 """ 

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

909 base = self.parallel_config.base_branch 

910 remote = self.parallel_config.remote_name 

911 fetch_result = subprocess.run( 

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

913 cwd=worktree_path, 

914 capture_output=True, 

915 text=True, 

916 timeout=60, 

917 ) 

918 

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

920 

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

922 rebase_result = subprocess.run( 

923 ["git", "rebase", rebase_target], 

924 cwd=worktree_path, 

925 capture_output=True, 

926 text=True, 

927 timeout=120, 

928 ) 

929 

930 if rebase_result.returncode != 0: 

931 # Abort the failed rebase 

932 subprocess.run( 

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

934 cwd=worktree_path, 

935 capture_output=True, 

936 timeout=10, 

937 ) 

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

939 

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

941 return True, "" 

942 

943 def _verify_work_was_done( 

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

945 ) -> tuple[bool, str]: 

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

947 

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

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

950 

951 Args: 

952 changed_files: List of files changed during processing 

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

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

955 

956 Returns: 

957 Tuple of (success, error_message) 

958 """ 

959 if not changed_files: 

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

961 

962 # Check if code changes are required 

963 if not self.parallel_config.require_code_changes: 

964 return True, "" 

965 

966 # Use shared verification function 

967 if verify_work_was_done(self.logger, changed_files): 

968 return True, "" 

969 

970 # Generate descriptive error with actual excluded files 

971 excluded_files = [ 

972 f 

973 for f in changed_files 

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

975 ] 

976 if excluded_files: 

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

978 if len(excluded_files) > 5: 

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

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

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

982 

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

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

985 

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

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

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

989 

990 Args: 

991 file_lower: Lowercase file path to check 

992 current_issue_id_lower: Lowercase issue ID of the current worker 

993 

994 Returns: 

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

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

997 """ 

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

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

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

1001 

1002 if not matches: 

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

1004 return False 

1005 

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

1007 for match in matches: 

1008 if match == current_issue_id_lower: 

1009 return False # File belongs to current worker 

1010 

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

1012 return True 

1013 

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

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

1016 

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

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

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

1020 and after worker execution. 

1021 

1022 Args: 

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

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

1025 

1026 Returns: 

1027 List of file paths that were leaked to main repo 

1028 """ 

1029 # Get current status of main repo 

1030 result = self._git_lock.run( 

1031 ["status", "--porcelain"], 

1032 cwd=self.repo_path, 

1033 timeout=30, 

1034 ) 

1035 

1036 if result.returncode != 0: 

1037 return [] 

1038 

1039 current_files: set[str] = set() 

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

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

1042 continue 

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

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

1045 # Handle renamed files (old -> new) 

1046 if " -> " in file_path: 

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

1048 current_files.add(file_path) 

1049 

1050 # Find new files that appeared during worker execution 

1051 new_files = current_files - baseline_status 

1052 

1053 # Filter to files likely related to this issue 

1054 issue_id_lower = issue_id.lower() 

1055 leaked_files: list[str] = [] 

1056 

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

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

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

1060 if dir_path: 

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

1062 if normalized not in source_prefixes: 

1063 source_prefixes.append(normalized) 

1064 

1065 for file_path in new_files: 

1066 # Skip state file (managed by orchestrator) 

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

1068 continue 

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

1070 if file_path == ".gitignore": 

1071 continue 

1072 

1073 # Check if file is related to this issue 

1074 file_lower = file_path.lower() 

1075 if issue_id_lower in file_lower: 

1076 leaked_files.append(file_path) 

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

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

1079 leaked_files.append(file_path) 

1080 # Catch thoughts/plans files 

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

1082 leaked_files.append(file_path) 

1083 # Catch issue files in any issue directory variant 

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

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

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

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

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

1089 leaked_files.append(file_path) 

1090 

1091 return leaked_files 

1092 

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

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

1095 

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

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

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

1099 safely discard these leaked changes from the main repo. 

1100 

1101 Args: 

1102 leaked_files: List of file paths leaked to main repo 

1103 

1104 Returns: 

1105 Number of files successfully cleaned up 

1106 """ 

1107 if not leaked_files: 

1108 return 0 

1109 

1110 cleaned = 0 

1111 

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

1113 status_result = self._git_lock.run( 

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

1115 cwd=self.repo_path, 

1116 timeout=30, 

1117 ) 

1118 

1119 tracked_files: list[str] = [] 

1120 untracked_files: list[str] = [] 

1121 

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

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

1124 continue 

1125 status_code = line[:2] 

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

1127 

1128 if status_code.startswith("?"): 

1129 # Untracked file - need to delete 

1130 untracked_files.append(file_path) 

1131 else: 

1132 # Tracked file - can use git checkout to discard 

1133 tracked_files.append(file_path) 

1134 

1135 # Discard changes to tracked files 

1136 if tracked_files: 

1137 checkout_result = self._git_lock.run( 

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

1139 cwd=self.repo_path, 

1140 timeout=30, 

1141 ) 

1142 if checkout_result.returncode == 0: 

1143 cleaned += len(tracked_files) 

1144 else: 

1145 self.logger.warning( 

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

1147 ) 

1148 

1149 # Delete untracked files 

1150 for file_path in untracked_files: 

1151 full_path = self.repo_path / file_path 

1152 try: 

1153 if full_path.exists(): 

1154 full_path.unlink() 

1155 cleaned += 1 

1156 except OSError as e: 

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

1158 

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

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

1161 accounted_files = set(tracked_files + untracked_files) 

1162 for file_path in leaked_files: 

1163 if file_path not in accounted_files: 

1164 full_path = self.repo_path / file_path 

1165 if full_path.exists(): 

1166 try: 

1167 full_path.unlink() 

1168 cleaned += 1 

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

1170 except OSError as e: 

1171 self.logger.warning( 

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

1173 ) 

1174 else: 

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

1176 

1177 if cleaned > 0: 

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

1179 

1180 return cleaned 

1181 

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

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

1184 

1185 Returns: 

1186 Set of file paths currently showing in git status 

1187 """ 

1188 result = self._git_lock.run( 

1189 ["status", "--porcelain"], 

1190 cwd=self.repo_path, 

1191 timeout=30, 

1192 ) 

1193 

1194 if result.returncode != 0: 

1195 return set() 

1196 

1197 files: set[str] = set() 

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

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

1200 continue 

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

1202 if " -> " in file_path: 

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

1204 files.add(file_path) 

1205 

1206 return files 

1207 

1208 def _get_main_head_sha(self) -> str: 

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

1210 

1211 Returns: 

1212 HEAD SHA string, or empty string if unavailable 

1213 """ 

1214 result = self._git_lock.run( 

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

1216 cwd=self.repo_path, 

1217 timeout=10, 

1218 ) 

1219 if result.returncode == 0: 

1220 return result.stdout.strip() 

1221 return "" 

1222 

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

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

1225 

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

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

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

1229 before and after worker execution. 

1230 

1231 Args: 

1232 baseline_head_sha: HEAD SHA captured before worker started 

1233 

1234 Returns: 

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

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

1237 """ 

1238 if not baseline_head_sha: 

1239 return [] 

1240 

1241 current_sha = self._get_main_head_sha() 

1242 if not current_sha or current_sha == baseline_head_sha: 

1243 return [] 

1244 

1245 # Get list of new commits on main since baseline 

1246 result = self._git_lock.run( 

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

1248 cwd=self.repo_path, 

1249 timeout=30, 

1250 ) 

1251 if result.returncode != 0: 

1252 return [] 

1253 

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

1255 return commits 

1256 

1257 def _recover_committed_leaks( 

1258 self, 

1259 leaked_commits: list[str], 

1260 worktree_path: Path, 

1261 baseline_head_sha: str, 

1262 issue_id: str, 

1263 ) -> bool: 

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

1265 

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

1267 we attempt to: 

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

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

1270 

1271 This preserves the implementation work in the worktree while 

1272 cleaning up the incorrect commits on main. 

1273 

1274 Args: 

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

1276 worktree_path: Path to the worker's worktree 

1277 baseline_head_sha: Main HEAD SHA before worker started 

1278 issue_id: Issue ID for logging 

1279 

1280 Returns: 

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

1282 not required for a True return value) 

1283 """ 

1284 self.logger.info( 

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

1286 f"commit(s) to worktree" 

1287 ) 

1288 

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

1290 for sha in reversed(leaked_commits): 

1291 result = subprocess.run( 

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

1293 cwd=worktree_path, 

1294 capture_output=True, 

1295 text=True, 

1296 timeout=60, 

1297 ) 

1298 if result.returncode != 0: 

1299 subprocess.run( 

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

1301 cwd=worktree_path, 

1302 capture_output=True, 

1303 timeout=10, 

1304 ) 

1305 self.logger.warning( 

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

1307 ) 

1308 return False 

1309 

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

1311 current_main_sha = self._get_main_head_sha() 

1312 most_recent_leaked = leaked_commits[0] # Newest first 

1313 if current_main_sha == most_recent_leaked: 

1314 reset_result = self._git_lock.run( 

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

1316 cwd=self.repo_path, 

1317 timeout=30, 

1318 ) 

1319 if reset_result.returncode == 0: 

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

1321 else: 

1322 self.logger.warning( 

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

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

1325 ) 

1326 else: 

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

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

1329 self.logger.info( 

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

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

1332 f"attempting surgical rebase to excise leaked commits" 

1333 ) 

1334 rebase_result = self._git_lock.run( 

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

1336 cwd=self.repo_path, 

1337 timeout=60, 

1338 ) 

1339 if rebase_result.returncode == 0: 

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

1341 else: 

1342 self._git_lock.run( 

1343 ["rebase", "--abort"], 

1344 cwd=self.repo_path, 

1345 timeout=10, 

1346 ) 

1347 self.logger.warning( 

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

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

1350 ) 

1351 

1352 self.logger.info( 

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

1354 f"cherry-picked to worktree branch" 

1355 ) 

1356 return True 

1357 

1358 @property 

1359 def active_count(self) -> int: 

1360 """Number of currently active workers. 

1361 

1362 Includes both workers with running futures AND workers whose futures 

1363 are done but callbacks haven't completed yet. 

1364 """ 

1365 with self._process_lock: 

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

1367 with self._callback_lock: 

1368 pending_callback_count = len(self._pending_callbacks) 

1369 return running_futures + pending_callback_count 

1370 

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

1372 """Update the stage of a worker. 

1373 

1374 Args: 

1375 issue_id: Issue ID being processed 

1376 stage: New stage value 

1377 """ 

1378 with self._process_lock: 

1379 self._worker_stages[issue_id] = stage 

1380 

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

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

1383 

1384 Args: 

1385 issue_id: Issue ID being processed 

1386 

1387 Returns: 

1388 Current stage, or None if issue not being tracked 

1389 """ 

1390 with self._process_lock: 

1391 return self._worker_stages.get(issue_id) 

1392 

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

1394 """Get all active worker stages. 

1395 

1396 Returns: 

1397 Dictionary mapping issue_id to current stage for active workers 

1398 """ 

1399 with self._process_lock: 

1400 # Only return workers that are actually active 

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

1402 return { 

1403 issue_id: stage 

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

1405 if issue_id in active_ids 

1406 } 

1407 

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

1409 """Remove a worker from stage tracking. 

1410 

1411 Args: 

1412 issue_id: Issue ID to remove 

1413 """ 

1414 with self._process_lock: 

1415 self._worker_stages.pop(issue_id, None) 

1416 

1417 def cleanup_all_worktrees(self) -> None: 

1418 """Clean up all worker worktrees.""" 

1419 worktree_base = self.repo_path / self.parallel_config.worktree_base 

1420 if not worktree_base.exists(): 

1421 return 

1422 

1423 from little_loops.worktree_utils import _is_ll_worktree 

1424 

1425 for worktree_dir in worktree_base.iterdir(): 

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

1427 self._cleanup_worktree(worktree_dir) 

1428 

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