1"""Main orchestrator for parallel issue processing.

2

3 Coordinates the priority queue, worker pool, and merge coordinator to process

4 multiple issues concurrently.

5"""

6

7 from __future__ import annotations

8

9 import json

10 import os

11 import re

12 import shutil

13 import signal

14 import subprocess

15 import tempfile

16 import threading

17 import time

18 from datetime import UTC, datetime

19 from pathlib import Path

20 from typing import TYPE_CHECKING, Any

21

22 from little_loops.events import EventBus

23 from little_loops.frontmatter import parse_frontmatter, update_frontmatter

24 from little_loops.issue_parser import IssueInfo

25 from little_loops.logger import Logger, format_duration

26 from little_loops.parallel.git_lock import GitLock

27 from little_loops.parallel.merge_coordinator import MergeCoordinator

28 from little_loops.parallel.overlap_detector import OverlapDetector

29 from little_loops.parallel.priority_queue import IssuePriorityQueue

30 from little_loops.parallel.types import (

31 OrchestratorState,

32 ParallelConfig,

33 PendingWorktreeInfo,

34 WorkerResult,

35)

36 from little_loops.parallel.worker_pool import WorkerPool

37 from little_loops.session_log import append_session_log_entry

38 from little_loops.worktree_utils import _is_ll_branch, _is_ll_worktree

39

40 if TYPE_CHECKING:

41 from little_loops.config import BRConfig

42

43

44 class ParallelOrchestrator:

45 """Main controller for parallel issue processing.

46

47 Coordinates:

48 - Issue scanning and prioritization

49 - Worker dispatch (P0 sequential, P1-P5 parallel)

50 - Merge coordination

51 - State persistence for resume capability

52 - Graceful shutdown on signals

53

54 Example:

55 >>> from little_loops.config import BRConfig

56 >>> from little_loops.parallel import ParallelConfig, ParallelOrchestrator

57 >>> br_config = BRConfig(Path.cwd())

58 >>> parallel_config = ParallelConfig(max_workers=2)

59 >>> orchestrator = ParallelOrchestrator(parallel_config, br_config)

60 >>> exit_code = orchestrator.run()

61 """

62

63 def __init__(

64 self,

65 parallel_config: ParallelConfig,

66 br_config: BRConfig,

67 repo_path: Path | None = None,

68 verbose: bool = True,

69 wave_label: str | None = None,

70 event_bus: EventBus | None = None,

71 ) -> None:

72 """Initialize the orchestrator.

73

74 Args:

75 parallel_config: Parallel processing configuration

76 br_config: Project configuration

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

78 verbose: Whether to output progress messages

79 wave_label: Optional label for wave-based execution (e.g., "Wave 1")

80 event_bus: Optional EventBus for emitting worker completion events

81 """

82 self.parallel_config = parallel_config

83 self.br_config = br_config

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

85 from little_loops.cli.output import use_color_enabled

86

87 self.logger = Logger(verbose=verbose, use_color=use_color_enabled())

88 self.wave_label = wave_label

89 self._event_bus = event_bus

90 self._execution_duration: float = 0.0

91

92 # Create shared git lock for serializing main repo operations

93 # This prevents index.lock race conditions between workers and merge coordinator

94 self._git_lock = GitLock(self.logger)

95

96 # Initialize components with shared git lock

97 self.queue = IssuePriorityQueue()

98 self.worker_pool = WorkerPool(

99 parallel_config, br_config, self.logger, self.repo_path, self._git_lock

100 )

101 self.merge_coordinator = MergeCoordinator(

102 parallel_config, self.logger, self.repo_path, self._git_lock

103 )

104

105 # State management

106 self.state = OrchestratorState()

107 self._state_lock = threading.Lock()

108 self._shutdown_requested = False

109 self._original_sigint: Any = None

110 self._original_sigterm: Any = None

111

112 # Track issue info for lifecycle completion after merge

113 self._issue_info_by_id: dict[str, IssueInfo] = {}

114 # Track interrupted issues separately from failures (ENH-036)

115 self._interrupted_issues: list[str] = []

116 # Accumulate per-issue failure reasons for state file (BUG-1383)

117 self._worker_errors: dict[str, str] = {}

118 # Track feature-branch state when use_feature_branches=True (ENH-665, BUG-2172)

119 self._pr_ready_branches: dict[str, dict] = {} # issue_id -> {branch_name, pushed, pr_url}

120

121 # Overlap detection (ENH-143)

122 self.overlap_detector: OverlapDetector | None = (

123 OverlapDetector(config=br_config.dependency_mapping)

124 if parallel_config.overlap_detection

125 else None

126 )

127 # Track deferred issues for re-check after active issues complete

128 self._deferred_issues: list[IssueInfo] = []

129 # Track last status report time for progress visibility (ENH-262)

130 self._last_status_time: float = 0.0

131 self._last_status_line: str = ""

132 # Track last state save time to throttle disk writes (ENH-485)

133 self._last_save_time: float = 0.0

134

135 @property

136 def execution_duration(self) -> float:

137 """Return the total execution duration in seconds."""

138 return self._execution_duration

139

140 def run(self) -> int:

141 """Run the parallel issue processor.

142

143 Returns:

144 Exit code (0 = success, 1 = failure)

145 """

146 try:

147 self._setup_signal_handlers()

148 self._ensure_gitignore_entries()

149

150 # Check for pending work from previous runs (unless clean start)

151 if not self.parallel_config.clean_start:

152 pending_worktrees = self._check_pending_worktrees()

153

154 # Handle pending worktrees based on flags

155 pending_with_work = [p for p in pending_worktrees if p.has_pending_work]

156 if pending_with_work:

157 if self.parallel_config.merge_pending:

158 self._merge_pending_worktrees(pending_worktrees)

159 elif not self.parallel_config.ignore_pending:

160 # Default behavior: just report (cleanup happens below)

161 self.logger.info(

162 "Continuing with cleanup (use --merge-pending to merge)..."

163 )

164

165 self._cleanup_orphaned_worktrees()

166 self._load_state()

167

168 if self.parallel_config.dry_run:

169 return self._dry_run()

170

171 return self._execute()

172

173 except KeyboardInterrupt:

174 self.logger.warning("Interrupted by user")

175 return 1

176 except Exception as e:

177 self.logger.error(f"Fatal error: {e}")

178 return 1

179 finally:

180 self._cleanup()

181 self._restore_signal_handlers()

182

183 def _setup_signal_handlers(self) -> None:

184 """Setup signal handlers for graceful shutdown."""

185 self._original_sigint = signal.signal(signal.SIGINT, self._signal_handler)

186 self._original_sigterm = signal.signal(signal.SIGTERM, self._signal_handler)

187

188 def _restore_signal_handlers(self) -> None:

189 """Restore original signal handlers."""

190 if self._original_sigint is not None:

191 signal.signal(signal.SIGINT, self._original_sigint)

192 if self._original_sigterm is not None:

193 signal.signal(signal.SIGTERM, self._original_sigterm)

194

195 def _ensure_gitignore_entries(self) -> None:

196 """Ensure .gitignore has entries for parallel processing artifacts.

197

198 Adds entries for:

199 - .parallel-manage-state.json (state file)

200 - .worktrees/ (git worktree directory)

201

202 This prevents these files from being tracked by git, which would cause

203 conflicts during merge operations (state file is continuously updated).

204 """

205 gitignore_path = self.repo_path / ".gitignore"

206 required_entries = [

207 ".parallel-manage-state.json",

208 ".worktrees/",

209 ]

210

211 existing_content = ""

212 if gitignore_path.exists():

213 existing_content = gitignore_path.read_text()

214

215 # Check which entries are missing

216 missing_entries = []

217 for entry in required_entries:

218 # Check for exact match or pattern that would cover it

219 if entry not in existing_content:

220 missing_entries.append(entry)

221

222 if not missing_entries:

223 return

224

225 # Append missing entries

226 addition = "\n# ll-parallel artifacts\n"

227 for entry in missing_entries:

228 addition += f"{entry}\n"

229

230 # Ensure file ends with newline before adding

231 if existing_content and not existing_content.endswith("\n"):

232 addition = "\n" + addition

233

234 gitignore_path.write_text(existing_content + addition)

235 self.logger.info(f"Added {len(missing_entries)} entries to .gitignore")

236

237 def _cleanup_orphaned_worktrees(self, dry_run: bool = False) -> None:

238 """Clean up worktrees from previous interrupted runs.

239

240 Scans the worktree base directory and removes any worktrees that are

241 not from the current session. This handles cases where a previous run

242 was interrupted (Ctrl+C) and worktrees were not cleaned up.

243

244 Args:

245 dry_run: If True, log what would be removed but make no changes.

246 """

247 worktree_base = self.repo_path / self.parallel_config.worktree_base

248 if not worktree_base.exists():

249 return

250

251 # Get list of worktree directories, skipping those owned by live processes (BUG-579)

252 orphaned = []

253 for item in worktree_base.iterdir():

254 if item.is_dir() and _is_ll_worktree(item.name):

255 # Check for a .ll-session-<pid> marker left by an active orchestrator

256 owned_by_live = False

257 for marker in item.glob(".ll-session-*"):

258 try:

259 pid = int(marker.name.split("-")[-1])

260 os.kill(pid, 0) # Signal 0: check if process exists

261 owned_by_live = True

262 break

263 except (ProcessLookupError, ValueError):

264 pass

265 except PermissionError:

266 owned_by_live = True # Process exists, no permission to signal

267 break

268 if owned_by_live:

269 self.logger.info(f"Skipping {item.name}: owned by running process")

270 continue

271 orphaned.append(item)

272

273 if orphaned:

274 self.logger.info(f"Cleaning up {len(orphaned)} orphaned worktree(s) from previous run")

275

276 for worktree_path in orphaned:

277 try:

278 # Resolve branch name BEFORE removing the worktree (BUG-2324)

279 branch_result = subprocess.run(

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

281 cwd=worktree_path,

282 capture_output=True,

283 text=True,

284 )

285 branch_name = (

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

287 )

288

289 if dry_run:

290 self.logger.info(

291 f"[dry-run] Would remove worktree {worktree_path.name}"

292 + (

293 f" and branch {branch_name}"

294 if branch_name and _is_ll_branch(branch_name)

295 else ""

296 )

297 )

298 continue

299

300 self._git_lock.run(

301 ["worktree", "unlock", str(worktree_path)],

302 cwd=self.repo_path,

303 timeout=10,

304 )

305 # Try git worktree remove first

306 self._git_lock.run(

307 ["worktree", "remove", "--force", str(worktree_path)],

308 cwd=self.repo_path,

309 timeout=30,

310 )

311

312 # If git worktree remove failed, force delete the directory

313 if worktree_path.exists():

314 shutil.rmtree(worktree_path, ignore_errors=True)

315

316 # Delete branch only for ll-managed shapes (BUG-2324: safe guard replaces

317 # the old parallel/-only guard to also cover loop worktree branches)

318 if branch_name and _is_ll_branch(branch_name):

319 self._git_lock.run(

320 ["branch", "-D", branch_name],

321 cwd=self.repo_path,

322 timeout=10,

323 )

324 except Exception as e:

325 self.logger.warning(f"Failed to clean up {worktree_path.name}: {e}")

326

327 if not dry_run:

328 # Also prune git worktree references

329 self._git_lock.run(

330 ["worktree", "prune"],

331 cwd=self.repo_path,

332 timeout=30,

333 )

334

335 self._prune_ghost_worktree_refs()

336

337 def _prune_ghost_worktree_refs(self) -> None:

338 """Prune git worktree metadata entries whose on-disk path no longer exists.

339

340 Handles the SIGKILL race where a worker directory was deleted before

341 git worktree prune ran, leaving .git/worktrees/<name>/ intact. The

342 next git worktree add for the same path would then fail with "already exists".

343 """

344 try:

345 result = self._git_lock.run(

346 ["worktree", "list", "--porcelain"],

347 cwd=self.repo_path,

348 timeout=30,

349 )

350 except Exception as e:

351 self.logger.warning(f"Failed to list worktrees for ghost ref scan: {e}")

352 return

353

354 ghost_names: list[str] = []

355 current: dict[str, str] = {}

356 for line in result.stdout.splitlines():

357 if not line:

358 if current:

359 path_str = current.get("worktree", "")

360 name = Path(path_str).name

361 if _is_ll_worktree(name) and path_str and not Path(path_str).exists():

362 ghost_names.append(name)

363 current = {}

364 continue

365 key, _, value = line.partition(" ")

366 current[key] = value

367 if current:

368 path_str = current.get("worktree", "")

369 name = Path(path_str).name

370 if _is_ll_worktree(name) and path_str and not Path(path_str).exists():

371 ghost_names.append(name)

372

373 if not ghost_names:

374 return

375

376 for name in ghost_names:

377 self.logger.info(f"Pruned ghost ref: {name}")

378

379 try:

380 self._git_lock.run(

381 ["worktree", "prune"],

382 cwd=self.repo_path,

383 timeout=30,

384 )

385 except Exception as e:

386 self.logger.warning(f"Failed to prune ghost worktree refs: {e}")

387

388 def _inspect_worktree(self, worktree_path: Path) -> PendingWorktreeInfo | None:

389 """Inspect a worktree to determine its status.

390

391 Args:

392 worktree_path: Path to the worktree directory

393

394 Returns:

395 PendingWorktreeInfo if inspection succeeded, None if failed

396 """

397 try:

398 # Read actual branch name from worktree via rev-parse

399 branch_result = subprocess.run(

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

401 cwd=worktree_path,

402 capture_output=True,

403 text=True,

404 )

405 if branch_result.returncode == 0:

406 branch_name = branch_result.stdout.strip()

407 else:

408 # Fall back to string derivation if rev-parse fails

409 branch_name = worktree_path.name.replace("worker-", "parallel/")

410

411 # Extract issue ID (e.g., bug-045 -> BUG-045)

412 # Pattern: worker-<issue-id>-<timestamp>

413 match = re.match(r"worker-([a-z]+-\d+)-\d{8}-\d{6}", worktree_path.name)

414 issue_id = match.group(1).upper() if match else worktree_path.name

415

416 # Check commits ahead of main

417 result = self._git_lock.run(

418 ["rev-list", "--count", f"{self.parallel_config.base_branch}..{branch_name}"],

419 cwd=self.repo_path,

420 timeout=10,

421 )

422 commits_ahead = int(result.stdout.strip()) if result.returncode == 0 else 0

423

424 # Check for uncommitted changes in worktree

425 result = self._git_lock.run(

426 ["status", "--porcelain"],

427 cwd=worktree_path,

428 timeout=10,

429 )

430 changed_files = []

431 has_uncommitted = False

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

433 has_uncommitted = True

434 changed_files = [line[3:] for line in result.stdout.strip().split("\n") if line]

435

436 return PendingWorktreeInfo(

437 worktree_path=worktree_path,

438 branch_name=branch_name,

439 issue_id=issue_id,

440 commits_ahead=commits_ahead,

441 has_uncommitted_changes=has_uncommitted,

442 changed_files=changed_files,

443 )

444 except Exception as e:

445 self.logger.warning(f"Failed to inspect worktree {worktree_path.name}: {e}")

446 return None

447

448 def _check_pending_worktrees(self) -> list[PendingWorktreeInfo]:

449 """Check for pending worktrees from previous runs and report status.

450

451 Returns:

452 List of pending worktree information

453 """

454 worktree_base = self.repo_path / self.parallel_config.worktree_base

455 if not worktree_base.exists():

456 return []

457

458 # Find all worker directories

459 worktrees = [

460 item for item in worktree_base.iterdir() if item.is_dir() and _is_ll_worktree(item.name)

461 ]

462

463 if not worktrees:

464 return []

465

466 self.logger.info("Checking for pending work from previous runs...")

467

468 # Inspect each worktree

469 pending_info: list[PendingWorktreeInfo] = []

470 for worktree_path in worktrees:

471 info = self._inspect_worktree(worktree_path)

472 if info:

473 pending_info.append(info)

474

475 # Report findings

476 with_work = [p for p in pending_info if p.has_pending_work]

477 if with_work:

478 self.logger.warning(f"Found {len(with_work)} worktree(s) with pending work:")

479 for info in with_work:

480 status_parts = []

481 if info.commits_ahead > 0:

482 status_parts.append(f"{info.commits_ahead} commit(s) ahead of main")

483 if info.has_uncommitted_changes:

484 status_parts.append(f"{len(info.changed_files)} uncommitted file(s)")

485 status = ", ".join(status_parts)

486 self.logger.warning(f" - {info.worktree_path.name}: {info.issue_id} ({status})")

487

488 self.logger.info("")

489 self.logger.info("Options:")

490 self.logger.info(" --merge-pending Attempt to merge pending work before continuing")

491 self.logger.info(" --clean-start Remove all worktrees and start fresh")

492 self.logger.info(

493 " --ignore-pending Continue without action (worktrees will be cleaned up)"

494 )

495 elif pending_info:

496 self.logger.info(f"Found {len(pending_info)} orphaned worktree(s) with no pending work")

497

498 return pending_info

499

500 def _merge_pending_worktrees(self, pending: list[PendingWorktreeInfo]) -> None:

501 """Attempt to merge pending worktrees from previous runs.

502

503 Args:

504 pending: List of pending worktree information

505 """

506 with_work = [p for p in pending if p.has_pending_work]

507 if not with_work:

508 return

509

510 self.logger.info(f"Attempting to merge {len(with_work)} pending worktree(s)...")

511

512 for info in with_work:

513 try:

514 # If there are uncommitted changes, commit them first

515 if info.has_uncommitted_changes:

516 self.logger.info(f" Committing uncommitted changes in {info.issue_id}...")

517 self._git_lock.run(

518 ["add", "-A"],

519 cwd=info.worktree_path,

520 timeout=30,

521 )

522 self._git_lock.run(

523 [

524 "commit",

525 "-m",

526 f"WIP: Auto-commit from interrupted session for {info.issue_id}",

527 ],

528 cwd=info.worktree_path,

529 timeout=30,

530 )

531

532 # Attempt merge

533 self.logger.info(f" Merging {info.issue_id} ({info.branch_name})...")

534 result = self._git_lock.run(

535 [

536 "merge",

537 "--no-ff",

538 info.branch_name,

539 "-m",

540 f"Merge pending work for {info.issue_id}",

541 ],

542 cwd=self.repo_path,

543 timeout=60,

544 )

545

546 if result.returncode == 0:

547 self.logger.success(f" Successfully merged {info.issue_id}")

548 # Clean up the worktree after successful merge

549 self._git_lock.run(

550 ["worktree", "remove", "--force", str(info.worktree_path)],

551 cwd=self.repo_path,

552 timeout=30,

553 )

554 self._git_lock.run(

555 ["branch", "-D", info.branch_name],

556 cwd=self.repo_path,

557 timeout=10,

558 )

559 else:

560 self.logger.warning(f" Failed to merge {info.issue_id}: {result.stderr}")

561 # Abort the merge if it failed

562 self._git_lock.run(

563 ["merge", "--abort"],

564 cwd=self.repo_path,

565 timeout=10,

566 )

567

568 except Exception as e:

569 self.logger.warning(f" Error merging {info.issue_id}: {e}")

570

571 def _signal_handler(self, signum: int, frame: object) -> None:

572 """Handle shutdown signals gracefully."""

573 self._shutdown_requested = True

574 # Propagate to worker pool for interrupted worker detection (ENH-036)

575 self.worker_pool.set_shutdown_requested(True)

576 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")

577

578 def _load_state(self) -> None:

579 """Load state from file for resume capability."""

580 if self.parallel_config.clean_start:

581 self.state.started_at = datetime.now().isoformat()

582 return

583 state_file = self.repo_path / self.parallel_config.state_file

584 if not state_file.exists():

585 self.state.started_at = datetime.now().isoformat()

586 return

587

588 try:

589 data = json.loads(state_file.read_text())

590 self.state = OrchestratorState.from_dict(data)

591

592 # Restore queue state

593 self.queue.load_completed(self.state.completed_issues)

594 self.queue.load_failed(self.state.failed_issues.keys())

595

596 self.logger.info(

597 f"Resumed from previous state: "

598 f"{len(self.state.completed_issues)} completed, "

599 f"{len(self.state.failed_issues)} failed"

600 )

601 except Exception as e:

602 self.logger.warning(f"Could not load state: {e}")

603 self.state.started_at = datetime.now().isoformat()

604

605 def _save_state(self, force: bool = False) -> None:

606 """Save current state to file using an atomic write.

607

608 Writes are throttled to at most once every 5 seconds to reduce filesystem I/O

609 during high-frequency loop ticks (e.g., merge-waiting phase). Pass force=True

610 to bypass the throttle, e.g., on shutdown.

611 """

612 now = time.time()

613 if not force and now - self._last_save_time < 5.0:

614 return

615 self._last_save_time = now

616 with self._state_lock:

617 self.state.last_checkpoint = datetime.now().isoformat()

618 self.state.completed_issues = self.queue.completed_ids

619 self.state.failed_issues = {

620 issue_id: self._worker_errors.get(issue_id, "Failed")

621 for issue_id in self.queue.failed_ids

622 }

623 self.state.in_progress_issues = self.queue.in_progress_ids

624

625 state_file = self.repo_path / self.parallel_config.state_file

626 data = json.dumps(self.state.to_dict(), indent=2)

627 tmp_fd, tmp_path = tempfile.mkstemp(dir=state_file.parent, suffix=".tmp")

628 try:

629 with os.fdopen(tmp_fd, "w") as f:

630 f.write(data)

631 os.replace(tmp_path, state_file)

632 except Exception:

633 os.unlink(tmp_path)

634 raise

635

636 def _cleanup_state(self) -> None:

637 """Remove state file on successful completion."""

638 state_file = self.repo_path / self.parallel_config.state_file

639 if state_file.exists():

640 state_file.unlink()

641

642 def _dry_run(self) -> int:

643 """Preview what would be processed without executing.

644

645 Returns:

646 Exit code (always 0 for dry run)

647 """

648 issues = self._scan_issues()

649

650 self.logger.info("=" * 60)

651 self.logger.info("DRY RUN - No changes will be made")

652 self.logger.info("=" * 60)

653 self.logger.info("")

654

655 if not issues:

656 self.logger.info("No issues found matching criteria")

657 return 0

658

659 self.logger.info(f"Found {len(issues)} issues to process:")

660 self.logger.info("")

661

662 # Group by priority

663 by_priority: dict[str, list[IssueInfo]] = {}

664 for issue in issues:

665 by_priority.setdefault(issue.priority, []).append(issue)

666

667 for priority in IssuePriorityQueue.DEFAULT_PRIORITIES:

668 if priority not in by_priority:

669 continue

670

671 priority_issues = by_priority[priority]

672 self.logger.info(f" {priority} ({len(priority_issues)} issues):")

673 for issue in priority_issues:

674 mode = (

675 "sequential"

676 if priority == "P0" and self.parallel_config.p0_sequential

677 else "parallel"

678 )

679 self.logger.info(f" - {issue.issue_id}: {issue.title} [{mode}]")

680

681 self.logger.info("")

682 self.logger.info("Configuration:")

683 self.logger.info(f" Workers: {self.parallel_config.max_workers}")

684 self.logger.info(f" P0 Sequential: {self.parallel_config.p0_sequential}")

685 self.logger.info(f" Max Issues: {self.parallel_config.max_issues or 'unlimited'}")

686 self.logger.info(f" Command Prefix: {self.parallel_config.command_prefix}")

687

688 return 0

689

690 def _maybe_report_status(self) -> None:

691 """Report status if enough time has elapsed since last report.

692

693 Reports every 5 seconds during active processing for progress visibility (ENH-262).

694 Suppresses duplicate lines when nothing has changed.

695 """

696 now = time.time()

697 # Report every 5 seconds

698 if now - self._last_status_time < 5.0:

699 return

700

701 self._last_status_time = now

702

703 # Build status line

704 parts = []

705

706 # Add wave label if present

707 if self.wave_label:

708 parts.append(f"{self.wave_label}")

709

710 # Get queue counts

711 in_progress = len(self.queue.in_progress_ids)

712 completed = self.queue.completed_count

713 failed = self.queue.failed_count

714 pending_merge = self.merge_coordinator.pending_count

715

716 parts.append(f"Active: {in_progress}")

717 parts.append(f"Done: {completed}")

718 if failed > 0:

719 parts.append(f"Failed: {failed}")

720 if pending_merge > 0:

721 parts.append(f"Merging: {pending_merge}")

722

723 # Build status line

724 status = " | ".join(parts)

725

726 # Get active worker stages

727 active_stages = self.worker_pool.get_active_stages()

728

729 # Add worker details if any are active

730 if active_stages:

731 # Group by stage

732 by_stage: dict[str, list[str]] = {}

733 for issue_id, worker_stage in active_stages.items():

734 stage_name = worker_stage.value.title()

735 by_stage.setdefault(stage_name, []).append(issue_id)

736

737 stage_parts = []

738 for stage_name in ["Validating", "Implementing", "Verifying", "Merging"]:

739 if stage_name in by_stage:

740 issue_ids = ", ".join(by_stage[stage_name])

741 stage_parts.append(f"{stage_name}: [{issue_ids}]")

742

743 if stage_parts:

744 status += " | " + " | ".join(stage_parts)

745

746 # Skip if nothing changed since last report

747 if status == self._last_status_line:

748 return

749 self._last_status_line = status

750

751 # Log with gray color to distinguish from normal logs

752 self.logger.debug(status)

753

754 def _execute(self) -> int:

755 """Execute parallel issue processing.

756

757 Returns:

758 Exit code (0 = success, 1 = failure)

759 """

760 start_time = time.time()

761

762 # Scan and queue issues

763 issues = self._scan_issues()

764 if not issues:

765 self.logger.info("No issues to process")

766 return 0

767

768 # Store issue info for lifecycle completion after merge

769 for issue in issues:

770 self._issue_info_by_id[issue.issue_id] = issue

771

772 added = self.queue.add_many(issues)

773 self.logger.info(f"Queued {added} issues for processing")

774

775 # Start components

776 self.worker_pool.start()

777 self.merge_coordinator.start()

778

779 # Process issues

780 issues_processed = 0

781 max_issues = self.parallel_config.max_issues or float("inf")

782

783 while not self._shutdown_requested:

784 # Check if done

785 if self.queue.empty() and self.worker_pool.active_count == 0:

786 # Wait for pending merges

787 if self.merge_coordinator.pending_count == 0:

788 break

789

790 # Check max issues limit

791 if issues_processed >= max_issues:

792 self.logger.info(f"Reached max issues limit ({max_issues})")

793 break

794

795 # Get next issue if workers available

796 if self.worker_pool.active_count < self.parallel_config.max_workers:

797 queued = self.queue.get(block=False)

798 if queued:

799 issue = queued.issue_info

800

801 # P0 sequential processing

802 if issue.priority == "P0" and self.parallel_config.p0_sequential:

803 self._process_sequential(issue)

804 else:

805 self._process_parallel(issue)

806

807 issues_processed += 1

808

809 # Save state periodically

810 self._save_state()

811

812 # Report status periodically for progress visibility (ENH-262)

813 self._maybe_report_status()

814

815 # Small sleep to prevent busy loop

816 time.sleep(0.1)

817

818 # Wait for completion

819 self._wait_for_completion()

820

821 # Report results

822 self._report_results(start_time)

823

824 # Cleanup state on success

825 if not self._shutdown_requested and self.queue.failed_count == 0:

826 self._cleanup_state()

827

828 return 0 if self.queue.failed_count == 0 else 1

829

830 def _scan_issues(self) -> list[IssueInfo]:

831 """Scan for issues matching criteria.

832

833 Returns:

834 List of issues sorted by priority

835 """

836 # Combine skip_ids from state and config

837 skip_ids = set(self.state.completed_issues) | set(self.state.failed_issues.keys())

838 if self.parallel_config.skip_ids:

839 skip_ids |= self.parallel_config.skip_ids

840

841 issues = IssuePriorityQueue.scan_issues(

842 self.br_config,

843 priority_filter=list(self.parallel_config.priority_filter),

844 skip_ids=skip_ids,

845 only_ids=self.parallel_config.only_ids,

846 type_prefixes=self.parallel_config.type_prefixes,

847 label_filter=self.parallel_config.label_filter,

848 )

849

850 # Apply max issues limit

851 if self.parallel_config.max_issues > 0:

852 issues = issues[: self.parallel_config.max_issues]

853

854 return issues

855

856 def _process_sequential(self, issue: IssueInfo) -> None:

857 """Process an issue sequentially (blocking).

858

859 Args:

860 issue: Issue to process

861 """

862 self.logger.info(f"Processing {issue.issue_id} sequentially (P0)")

863

864 # Wait for any parallel work to finish

865 while self.worker_pool.active_count > 0:

866 time.sleep(0.5)

867

868 # Process in main repo (no worktree isolation needed)

869 # Note: No callback here - _merge_sequential handles the result explicitly

870 # to avoid double-processing (callback would also queue merge/close)

871 future = self.worker_pool.submit(issue)

872

873 # Wait for completion

874 try:

875 result = future.result(timeout=self.parallel_config.timeout_per_issue)

876 if result.success:

877 # Merge immediately for P0

878 self._merge_sequential(result)

879 except Exception as e:

880 self.logger.error(f"Sequential processing failed: {e}")

881 self.queue.mark_failed(issue.issue_id)

882

883 def _process_parallel(self, issue: IssueInfo) -> None:

884 """Process an issue in parallel (non-blocking).

885

886 Args:

887 issue: Issue to process

888 """

889 # Check for overlaps if enabled (ENH-143)

890 if self.overlap_detector:

891 overlap = self.overlap_detector.check_overlap(issue)

892 if overlap:

893 if self.parallel_config.serialize_overlapping:

894 self.logger.warning(

895 f"Deferring {issue.issue_id} - overlaps with {overlap.overlapping_issues}"

896 )

897 # Track for re-check when active issues complete

898 self._deferred_issues.append(issue)

899 return

900 else:

901 self.logger.warning(

902 f"Warning: {issue.issue_id} may conflict with {overlap.overlapping_issues}"

903 )

904

905 # Register as active before dispatch

906 self.overlap_detector.register_issue(issue)

907

908 self.logger.info(f"Dispatching {issue.issue_id} to worker pool")

909 self.worker_pool.submit(issue, self._on_worker_complete)

910

911 def _on_worker_complete(self, result: WorkerResult) -> None:

912 """Callback when a worker completes.

913

914 Args:

915 result: Result from the worker

916 """

917 # Unregister from overlap tracking (ENH-143)

918 if self.overlap_detector:

919 self.overlap_detector.unregister_issue(result.issue_id)

920 # Re-queue deferred issues that were waiting on this one

921 self._requeue_deferred_issues()

922

923 # Handle interrupted workers (not counted as failed) - ENH-036

924 if result.interrupted:

925 self.logger.info(f"{result.issue_id} was interrupted during shutdown (can retry)")

926 self._interrupted_issues.append(result.issue_id)

927 # Don't mark as failed - they can be retried on next run

928 return

929

930 # Handle issue closure (no merge needed)

931 if result.should_close:

932 # Lazy import to avoid circular dependency

933 from little_loops.issue_lifecycle import close_issue

934

935 self.logger.info(f"{result.issue_id} should be closed: {result.close_status}")

936 info = self._issue_info_by_id.get(result.issue_id)

937 if info:

938 # TODO(ENH-1686): parallel-path close events not yet live-written

939 if close_issue(

940 info,

941 self.br_config,

942 self.logger,

943 result.close_reason,

944 result.close_status,

945 interceptors=None,

946 ):

947 self.queue.mark_completed(result.issue_id)

948 else:

949 self._worker_errors[result.issue_id] = (

950 f"Close failed: {result.close_reason or 'close error'}"

951 )

952 self.queue.mark_failed(result.issue_id)

953 else:

954 self.logger.warning(f"No issue info found for {result.issue_id}")

955 self._worker_errors[result.issue_id] = "Close failed: no issue info"

956 self.queue.mark_failed(result.issue_id)

957 elif result.success:

958 self.logger.success(

959 f"{result.issue_id} completed in {format_duration(result.duration)}"

960 )

961 if result.was_corrected:

962 self.logger.info(f"{result.issue_id} was auto-corrected during validation")

963 # Log and store corrections for pattern analysis (ENH-010)

964 for correction in result.corrections:

965 self.logger.info(f" Correction: {correction}")

966 if result.corrections:

967 with self._state_lock:

968 self.state.corrections[result.issue_id] = result.corrections

969 if self.parallel_config.use_feature_branches:

970 # Feature branch mode: skip auto-merge, branch stays alive (ENH-665, BUG-2172)

971 # Hold issue at in_progress until PR is merged; ll-sync reconcile promotes to done (ENH-2182)

972 self.logger.info(f"{result.issue_id}: feature branch ready — {result.branch_name}")

973 self.queue.mark_completed(result.issue_id)

974 self._complete_issue_lifecycle_if_needed(

975 result.issue_id, terminal_status="in_progress"

976 )

977 branch_state: dict[str, Any] = {

978 "branch_name": result.branch_name,

979 "pushed": False,

980 "pr_url": None,

981 }

982 if self.parallel_config.push_feature_branches:

983 push_result = subprocess.run(

984 [

985 "git",

986 "push",

987 "--force-with-lease",

988 self.parallel_config.remote_name,

989 result.branch_name,

990 ],

991 cwd=self.repo_path,

992 capture_output=True,

993 text=True,

994 timeout=60,

995 )

996 if push_result.returncode == 0:

997 branch_state["pushed"] = True

998 self.logger.info(

999 f"{result.issue_id}: pushed {result.branch_name}"

1000 f" to {self.parallel_config.remote_name}"

1001 )

1002 if self.parallel_config.open_pr_for_feature_branches:

1003 self._open_pr_for_branch(

1004 result.issue_id, result.branch_name, branch_state

1005 )

1006 else:

1007 self.logger.warning(

1008 f"{result.issue_id}: git push failed: {push_result.stderr.strip()}"

1009 )

1010 # Write branch name (and optional PR URL) back to issue frontmatter (ENH-2175)

1011 info = self._issue_info_by_id.get(result.issue_id)

1012 if info and info.path.exists():

1013 try:

1014 content = info.path.read_text()

1015 fm = parse_frontmatter(content)

1016 updates: dict[str, Any] = {"branch": result.branch_name}

1017 if branch_state.get("pr_url") and not fm.get("pr_url"):

1018 updates["pr_url"] = branch_state["pr_url"]

1019 content = update_frontmatter(content, updates)

1020 info.path.write_text(content)

1021 self._git_lock.run(["add", "-A"], cwd=self.repo_path)

1022 commit_result = self._git_lock.run(

1023 [

1024 "commit",

1025 "-m",

1026 f"{result.issue_id}: record feature branch in frontmatter",

1027 ],

1028 cwd=self.repo_path,

1029 )

1030 if (

1031 commit_result.returncode != 0

1032 and "nothing to commit" not in commit_result.stdout.lower()

1033 ):

1034 self.logger.warning(

1035 f"{result.issue_id}: branch frontmatter commit failed:"

1036 f" {commit_result.stderr}"

1037 )

1038 except Exception as exc:

1039 self.logger.warning(

1040 f"{result.issue_id}: failed to record branch in frontmatter: {exc}"

1041 )

1042 with self._state_lock:

1043 self._pr_ready_branches[result.issue_id] = branch_state

1044 else:

1045 self.merge_coordinator.queue_merge(result)

1046 # Wait for merge to complete before returning from callback.

1047 # This prevents dispatch of next worker while merge is in progress,

1048 # avoiding race conditions between worktree creation and merge ops.

1049 # (BUG-140: Race condition between worktree creation and merge)

1050 self.merge_coordinator.wait_for_completion(timeout=120)

1051 if result.issue_id in self.merge_coordinator.merged_ids:

1052 self.queue.mark_completed(result.issue_id)

1053 self._complete_issue_lifecycle_if_needed(result.issue_id)

1054 else:

1055 self._worker_errors[result.issue_id] = (

1056 f"Merge failed: {result.error or 'merge error'}"

1057 )

1058 self.queue.mark_failed(result.issue_id)

1059 else:

1060 self.logger.error(f"{result.issue_id} failed: {result.error}")

1061 self._worker_errors[result.issue_id] = result.error or "Failed"

1062 self.queue.mark_failed(result.issue_id)

1063

1064 # Update timing

1065 with self._state_lock:

1066 self.state.timing[result.issue_id] = {

1067 "total": result.duration,

1068 }

1069

1070 # Clean up stage tracking after callback completes (ENH-262)

1071 # Delay briefly so status reporter can show completion

1072 self.worker_pool.remove_worker_stage(result.issue_id)

1073

1074 # Emit worker completion event for extensions (ENH-921)

1075 if self._event_bus:

1076 self._event_bus.emit(

1077 {

1078 "event": "parallel.worker_completed",

1079 "ts": datetime.now(UTC).isoformat(),

1080 "issue_id": result.issue_id,

1081 "worker_name": result.worktree_path.name,

1082 "status": "success" if result.success else "failure",

1083 "duration_seconds": result.duration,

1084 }

1085 )

1086

1087 def _requeue_deferred_issues(self) -> None:

1088 """Re-queue deferred issues that no longer have overlaps (ENH-143)."""

1089 if not self._deferred_issues:

1090 return

1091

1092 # Check each deferred issue for remaining overlaps

1093 still_deferred = []

1094 for issue in self._deferred_issues:

1095 if self.overlap_detector:

1096 overlap = self.overlap_detector.check_overlap(issue)

1097 if overlap:

1098 # Still has overlaps, keep deferred

1099 still_deferred.append(issue)

1100 else:

1101 # No more overlaps, add back to queue

1102 self.logger.info(f"Re-queuing {issue.issue_id} - no longer overlapping")

1103 self.queue.add(issue)

1104

1105 self._deferred_issues = still_deferred

1106

1107 def _open_pr_for_branch(

1108 self,

1109 issue_id: str,

1110 branch_name: str,

1111 branch_state: dict[str, Any],

1112 ) -> None:

1113 """Open a draft PR for a pushed feature branch using the gh CLI.

1114

1115 Mutates branch_state in place to record pr_url on success.

1116 Degrades gracefully if gh is missing or unauthenticated.

1117 """

1118 try:

1119 auth_result = subprocess.run(

1120 ["gh", "auth", "status"],

1121 capture_output=True,

1122 text=True,

1123 timeout=30,

1124 )

1125 if auth_result.returncode != 0:

1126 self.logger.warning(f"{issue_id}: gh not authenticated, skipping PR creation")

1127 return

1128 issue_info = self._issue_info_by_id.get(issue_id)

1129 pr_title = issue_info.title if issue_info else issue_id

1130 pr_result = subprocess.run(

1131 [

1132 "gh",

1133 "pr",

1134 "create",

1135 "--title",

1136 pr_title,

1137 "--body",

1138 f"Closes {issue_id}",

1139 "--base",

1140 self.parallel_config.base_branch,

1141 "--draft",

1142 "--head",

1143 branch_name,

1144 ],

1145 cwd=self.repo_path,

1146 capture_output=True,

1147 text=True,

1148 timeout=60,

1149 )

1150 if pr_result.returncode == 0:

1151 branch_state["pr_url"] = pr_result.stdout.strip()

1152 self.logger.info(f"{issue_id}: PR opened: {branch_state['pr_url']}")

1153 else:

1154 self.logger.warning(f"{issue_id}: gh pr create failed: {pr_result.stderr.strip()}")

1155 except FileNotFoundError:

1156 self.logger.warning(f"{issue_id}: gh CLI not found, skipping PR creation")

1157 except subprocess.TimeoutExpired:

1158 self.logger.warning(f"{issue_id}: gh pr create timed out")

1159

1160 def _merge_sequential(self, result: WorkerResult) -> None:

1161 """Merge a sequential (P0) result immediately.

1162

1163 Args:

1164 result: Result to merge

1165 """

1166 # Handle closure for sequential issues

1167 if result.should_close:

1168 # Lazy import to avoid circular dependency

1169 from little_loops.issue_lifecycle import close_issue

1170

1171 info = self._issue_info_by_id.get(result.issue_id)

1172 # TODO(ENH-1686): parallel-path close events not yet live-written

1173 if info and close_issue(

1174 info,

1175 self.br_config,

1176 self.logger,

1177 result.close_reason,

1178 result.close_status,

1179 interceptors=None,

1180 ):

1181 self.queue.mark_completed(result.issue_id)

1182 else:

1183 self._worker_errors[result.issue_id] = (

1184 f"Close failed: {result.close_reason or 'close error'}"

1185 )

1186 self.queue.mark_failed(result.issue_id)

1187 return

1188

1189 self.merge_coordinator.queue_merge(result)

1190 # Wait for this specific merge

1191 self.merge_coordinator.wait_for_completion(timeout=60)

1192

1193 if result.issue_id in self.merge_coordinator.merged_ids:

1194 self.queue.mark_completed(result.issue_id)

1195 self._complete_issue_lifecycle_if_needed(result.issue_id)

1196 else:

1197 self._worker_errors[result.issue_id] = f"Merge failed: {result.error or 'merge error'}"

1198 self.queue.mark_failed(result.issue_id)

1199

1200 def _wait_for_completion(self) -> None:

1201 """Wait for all workers and merges to complete."""

1202 self.logger.info("Waiting for workers to complete...")

1203

1204 # Calculate timeout

1205 if self.parallel_config.orchestrator_timeout > 0:

1206 timeout = self.parallel_config.orchestrator_timeout

1207 else:

1208 timeout = self.parallel_config.timeout_per_issue * self.parallel_config.max_workers

1209

1210 start = time.time()

1211 while self.worker_pool.active_count > 0:

1212 if time.time() - start > timeout:

1213 self.logger.warning(f"Timeout waiting for workers after {timeout}s")

1214 self.worker_pool.terminate_all_processes()

1215 break

1216 time.sleep(1.0)

1217

1218 # Wait for merges

1219 self.logger.info("Waiting for pending merges...")

1220 self.merge_coordinator.wait_for_completion(timeout=120)

1221

1222 # Update queue with merge results and complete lifecycle

1223 for issue_id in self.merge_coordinator.merged_ids:

1224 self.queue.mark_completed(issue_id)

1225 self._complete_issue_lifecycle_if_needed(issue_id)

1226

1227 for issue_id, reason in self.merge_coordinator.failed_merges.items():

1228 self._worker_errors[issue_id] = reason or "Merge failed"

1229 self.queue.mark_failed(issue_id)

1230

1231 def _report_results(self, start_time: float) -> None:

1232 """Report processing results.

1233

1234 Args:

1235 start_time: When processing started

1236 """

1237 total_time = time.time() - start_time

1238 self._execution_duration = total_time

1239

1240 self.logger.info("")

1241 self.logger.info("=" * 60)

1242 if self.wave_label:

1243 self.logger.info(f"{self.wave_label.upper()} PROCESSING COMPLETE")

1244 else:

1245 self.logger.info("PARALLEL ISSUE PROCESSING COMPLETE")

1246 self.logger.info("=" * 60)

1247 self.logger.info("")

1248 self.logger.timing(f"Total time: {format_duration(total_time)}")

1249 self.logger.info(f"Completed: {self.queue.completed_count}")

1250 self.logger.info(f"Failed: {self.queue.failed_count}")

1251 if self._interrupted_issues:

1252 self.logger.info(f"Interrupted: {len(self._interrupted_issues)}")

1253

1254 with self._state_lock:

1255 timing_snapshot = dict(self.state.timing)

1256 corrections_snapshot = dict(self.state.corrections)

1257

1258 if self.queue.completed_count > 0:

1259 total_issue_time = sum(t.get("total", 0) for t in timing_snapshot.values())

1260 if total_issue_time > 0:

1261 speedup = total_issue_time / total_time

1262 self.logger.info(f"Estimated speedup: {speedup:.2f}x")

1263

1264 if self.queue.failed_ids:

1265 self.logger.info("")

1266 self.logger.warning("Failed issues:")

1267 for issue_id in self.queue.failed_ids:

1268 self.logger.warning(f" - {issue_id}")

1269

1270 # Report interrupted issues separately (ENH-036)

1271 if self._interrupted_issues:

1272 self.logger.info("")

1273 self.logger.info(f"Interrupted: {len(self._interrupted_issues)} (can retry)")

1274 for issue_id in self._interrupted_issues:

1275 self.logger.info(f" - {issue_id}")

1276

1277 # Report feature branches with actual per-branch state (ENH-665, BUG-2172)

1278 if self._pr_ready_branches:

1279 self.logger.info("")

1280 self.logger.info(f"Feature branches: {len(self._pr_ready_branches)} branch(es)")

1281 for issue_id, state in self._pr_ready_branches.items():

1282 branch = state["branch_name"]

1283 if state.get("pr_url"):

1284 self.logger.info(

1285 f" - {issue_id}: {branch} — pushed + PR opened: {state['pr_url']}"

1286 )

1287 elif state.get("pushed"):

1288 self.logger.info(f" - {issue_id}: {branch} — pushed (PR skipped)")

1289 else:

1290 self.logger.info(f" - {issue_id}: {branch} — local-only branch retained")

1291

1292 # Report correction statistics for quality tracking (ENH-010)

1293 if corrections_snapshot:

1294 total_corrected = len(corrections_snapshot)

1295 total_issues = self.queue.completed_count + self.queue.failed_count

1296 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0

1297 self.logger.info("")

1298 self.logger.info(

1299 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"

1300 )

1301

1302 # Group corrections by category (ENH-010 fourth fix)

1303 from collections import Counter, defaultdict

1304

1305 all_corrections: list[str] = []

1306 by_category: dict[str, int] = defaultdict(int)

1307 for corrections in corrections_snapshot.values():

1308 all_corrections.extend(corrections)

1309 for correction in corrections:

1310 # Extract category from [category] prefix if present

1311 if correction.startswith("[") and "]" in correction:

1312 category = correction[1 : correction.index("]")]

1313 by_category[category] += 1

1314 else:

1315 by_category["uncategorized"] += 1

1316

1317 # Log corrections by type/category

1318 if by_category:

1319 self.logger.info("Corrections by type:")

1320 for category, count in sorted(by_category.items(), key=lambda x: -x[1]):

1321 self.logger.info(f" - {category}: {count}")

1322

1323 # Log most common individual corrections

1324 if all_corrections:

1325 common = Counter(all_corrections).most_common(3)

1326 self.logger.info("Most common corrections:")

1327 for correction, count in common:

1328 # Truncate long correction descriptions

1329 display = correction[:60] + "..." if len(correction) > 60 else correction

1330 self.logger.info(f" - {display}: {count}")

1331

1332 # Report stash pop warnings (local changes need manual recovery)

1333 stash_warnings = self.merge_coordinator.stash_pop_failures

1334 if stash_warnings:

1335 self.logger.info("")

1336 self.logger.warning("Stash recovery warnings (local changes need manual restoration):")

1337 for issue_id, message in stash_warnings.items():

1338 self.logger.warning(f" - {issue_id}: {message}")

1339 self.logger.warning("")

1340 self.logger.warning(

1341 "To recover: Run 'git stash list' to find your changes, "

1342 "then 'git stash pop' or 'git stash apply stash@{N}'"

1343 )

1344

1345 def _complete_issue_lifecycle_if_needed(

1346 self, issue_id: str, terminal_status: str = "done"

1347 ) -> bool:

1348 """Complete issue lifecycle by writing status to frontmatter.

1349

1350 Args:

1351 issue_id: ID of the issue to complete

1352 terminal_status: Status to write — ``"done"`` for auto-merge,

1353 ``"in_progress"`` for feature-branch hold (ENH-2182)

1354

1355 Returns:

1356 True if lifecycle was completed (or already complete), False on error

1357 """

1358 # TODO(ENH-1686): parallel-path close events not yet live-written

1359 info = self._issue_info_by_id.get(issue_id)

1360 if not info:

1361 self.logger.warning(f"No issue info found for {issue_id}")

1362 return False

1363

1364 original_path = info.path

1365

1366 if not original_path.exists():

1367 return True

1368

1369 self.logger.info(f"Completing lifecycle for {issue_id} (frontmatter status update)")

1370

1371 try:

1372 content = original_path.read_text()

1373

1374 # Add resolution section if not already present

1375 if "## Resolution" not in content:

1376 action = self.br_config.get_category_action(info.issue_type)

1377 if terminal_status == "in_progress":

1378 status_label = "Branch ready, awaiting PR merge"

1379 impl_note = "Feature branch created; PR pending review and merge"

1380 else:

1381 status_label = "Completed (parallel merge fallback)"

1382 impl_note = "Merged from parallel worker branch"

1383 resolution = f"""

1384

1385---

1386

1387## Resolution

1388

1389- **Action**: {action}

1390- **Completed**: {datetime.now(UTC).strftime("%Y-%m-%d")}

1391- **Status**: {status_label}

1392- **Implementation**: {impl_note}

1393

1394### Changes Made

1395- See git history for implementation details

1396

1397### Verification Results

1398- Work verification passed before merge

1399

1400### Commits

1401- See `git log --oneline` for merge commit details

1402"""

1403 content += resolution

1404

1405 fm_updates: dict[str, Any] = {"status": terminal_status}

1406 if terminal_status == "done":

1407 fm_updates["completed_at"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

1408 content = update_frontmatter(content, fm_updates)

1409 original_path.write_text(content)

1410 append_session_log_entry(original_path, "ll-parallel")

1411

1412 # Stage and commit

1413 self._git_lock.run(

1414 ["add", "-A"],

1415 cwd=self.repo_path,

1416 )

1417

1418 action = self.br_config.get_category_action(info.issue_type)

1419 if terminal_status == "in_progress":

1420 lifecycle_note = "Feature branch ready — status: in_progress (awaiting PR merge)"

1421 else:

1422 lifecycle_note = "Parallel merge fallback — status: done written to frontmatter"

1423 commit_msg = f"""{action}({info.issue_type}): complete {issue_id} lifecycle

1424

1425{lifecycle_note}

1426

1427 Issue: {issue_id}

1428 Type: {info.issue_type}

1429 Title: {info.title}

1430"""

1431 commit_result = self._git_lock.run(

1432 ["commit", "-m", commit_msg],

1433 cwd=self.repo_path,

1434 )

1435

1436 if commit_result.returncode != 0:

1437 if "nothing to commit" not in commit_result.stdout.lower():

1438 self.logger.warning(f"git commit failed: {commit_result.stderr}")

1439 else:

1440 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout)

1441 if commit_hash_match:

1442 self.logger.success(

1443 f"Completed lifecycle for {issue_id}: {commit_hash_match.group(1)}"

1444 )

1445 else:

1446 self.logger.success(f"Completed lifecycle for {issue_id}")

1447

1448 return True

1449

1450 except Exception as e:

1451 self.logger.error(f"Failed to complete lifecycle for {issue_id}: {e}")

1452 return False

1453

1454 def _cleanup(self) -> None:

1455 """Clean up resources."""

1456 self.logger.info("Cleaning up...")

1457

1458 # Save final state (force=True bypasses throttle to ensure shutdown state is persisted)

1459 self._save_state(force=True)

1460

1461 # Shutdown components

1462 self.worker_pool.shutdown(wait=True)

1463 self.merge_coordinator.shutdown(wait=True, timeout=30)

1464

1465 # Flush transports regardless of interrupt state so events are not lost.

1466 if self._event_bus is not None:

1467 self._event_bus.close_transports()

1468

1469 # Clean up worktrees if not interrupted

1470 if not self._shutdown_requested:

1471 self.worker_pool.cleanup_all_worktrees()