Coverage for src/core/protocols.py: 100%

1"""Protocol definitions for pipeline stage abstractions.

2

3 This module defines Protocol classes that enable dependency injection and

4 testability for the MalaOrchestrator's pipeline stages. Each protocol

5 represents a stage boundary that the orchestrator interacts with.

6

7 Design principles:

8- Protocols use structural typing (typing.Protocol) for flexibility

9- Methods match exactly what the orchestrator actually calls

10- Result types are defined as local Protocol types to avoid import-time dependencies

11- BeadsClient, ReviewRunner, and QualityGate conform to these protocols

12

13 Usage:

14 These protocols enable:

15 1. Mock implementations for unit testing the orchestrator

16 2. Alternative implementations (e.g., in-memory issue tracker)

17 3. Clear contracts between orchestrator and its dependencies

18"""

19

20 from __future__ import annotations

21

22 from dataclasses import dataclass

23 from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

24

25 if TYPE_CHECKING:

26 from collections.abc import AsyncIterator, Iterator, Mapping, Sequence

27 from pathlib import Path

28 from types import TracebackType

29 from typing import Self

30

31

32# =============================================================================

33# Local Protocol Types

34# =============================================================================

35# These Protocol types replace TYPE_CHECKING imports from domain/infra modules

36# to satisfy the "Layered Architecture" contract. They define the structural

37# shape that protocols.py needs without creating import-time dependencies.

38#

39# Each Protocol matches only the attributes/methods that protocols.py actually

40# uses, following the Interface Segregation Principle.

41#

42# Note: These protocols use plain attributes (not @property) to be compatible

43# with dataclass implementations. Protocol structural typing matches attributes

44# regardless of whether they're defined as properties or plain attributes.

45# =============================================================================

46

47

48@runtime_checkable

49 class JsonlEntryProtocol(Protocol):

50 """Protocol for parsed JSONL log entries with byte offset tracking.

51

52 Matches the shape of session_log_parser.JsonlEntry for structural typing.

53 """

54

55 data: dict[str, Any]

56 """The parsed JSON object from this line."""

57

58 entry: object | None

59 """The typed LogEntry if successfully parsed, None otherwise."""

60

61 line_len: int

62 """Length of the raw line in bytes (for offset tracking)."""

63

64 offset: int

65 """Byte offset where this line started in the file."""

66

67

68@runtime_checkable

69 class ValidationSpecProtocol(Protocol):

70 """Protocol for validation specification.

71

72 Matches the shape of validation.spec.ValidationSpec for structural typing.

73 Only includes attributes/methods that protocols.py method signatures use.

74 """

75

76 commands: Sequence[Any]

77 """List of validation commands to run."""

78

79 scope: Any

80 """The validation scope (per-issue or run-level)."""

81

82

83@runtime_checkable

84 class ValidationEvidenceProtocol(Protocol):

85 """Protocol for validation evidence from agent runs.

86

87 Matches the shape of quality_gate.ValidationEvidence for structural typing.

88 """

89

90 commands_ran: dict[Any, bool]

91 """Mapping of CommandKind to whether it ran."""

92

93 failed_commands: list[str]

94 """List of validation commands that failed."""

95

96 def has_any_evidence(self) -> bool:

97 """Check if any validation command ran."""

98 ...

99

100 def to_evidence_dict(self) -> dict[str, bool]:

101 """Convert evidence to a serializable dict keyed by CommandKind value."""

102 ...

103

104

105@runtime_checkable

106 class CommitResultProtocol(Protocol):

107 """Protocol for commit existence check results.

108

109 Matches the shape of quality_gate.CommitResult for structural typing.

110 """

111

112 exists: bool

113 """Whether a matching commit exists."""

114

115 commit_hash: str | None

116 """The commit hash if found."""

117

118 message: str | None

119 """The commit message if found."""

120

121

122@runtime_checkable

123 class IssueResolutionProtocol(Protocol):

124 """Protocol for issue resolution records.

125

126 Matches the shape of models.IssueResolution for structural typing.

127 """

128

129 outcome: Any

130 """The resolution outcome (success, no_change, obsolete, etc.)."""

131

132 rationale: str

133 """Explanation for the resolution."""

134

135

136@runtime_checkable

137 class GateResultProtocol(Protocol):

138 """Protocol for quality gate check results.

139

140 Matches the shape of quality_gate.GateResult for structural typing.

141 """

142

143 passed: bool

144 """Whether the quality gate passed."""

145

146 failure_reasons: list[str]

147 """List of reasons why the gate failed."""

148

149 commit_hash: str | None

150 """The commit hash if found."""

151

152 validation_evidence: ValidationEvidenceProtocol | None

153 """Evidence of validation commands executed."""

154

155 no_progress: bool

156 """Whether no progress was detected."""

157

158 resolution: IssueResolutionProtocol | None

159 """Issue resolution if applicable."""

160

161

162@runtime_checkable

163 class ReviewIssueProtocol(Protocol):

164 """Protocol for review issues found during code review.

165

166 Matches the shape of cerberus_review.ReviewIssue for structural typing.

167 """

168

169 file: str

170 """File path where the issue was found."""

171

172 line_start: int

173 """Starting line number."""

174

175 line_end: int

176 """Ending line number."""

177

178 priority: int | None

179 """Issue priority (0=P0, 1=P1, etc.)."""

180

181 title: str

182 """Issue title."""

183

184 body: str

185 """Issue body/description."""

186

187 reviewer: str

188 """Which reviewer found this issue."""

189

190

191@runtime_checkable

192 class ReviewResultProtocol(Protocol):

193 """Protocol for code review results.

194

195 Matches the shape of cerberus_review.ReviewResult for structural typing.

196 """

197

198 passed: bool

199 """Whether the review passed."""

200

201 issues: Sequence[ReviewIssueProtocol]

202 """List of issues found during review."""

203

204 parse_error: str | None

205 """Parse error message if JSON parsing failed."""

206

207 fatal_error: bool

208 """Whether this is a fatal error (should not retry)."""

209

210 review_log_path: Path | None

211 """Path to review session logs."""

212

213

214@runtime_checkable

215 class UnmetCriterionProtocol(Protocol):

216 """Protocol for unmet criteria during epic verification.

217

218 Matches the shape of models.UnmetCriterion for structural typing.

219 """

220

221 criterion: str

222 """The acceptance criterion not met."""

223

224 evidence: str

225 """Why it's considered unmet."""

226

227 priority: int

228 """Issue priority matching Cerberus levels (0-3). P0/P1 blocking, P2/P3 informational."""

229

230 criterion_hash: str

231 """SHA256 of criterion text, for deduplication."""

232

233

234@runtime_checkable

235 class EpicVerdictProtocol(Protocol):

236 """Protocol for epic verification verdicts.

237

238 Matches the shape of models.EpicVerdict for structural typing.

239 """

240

241 passed: bool

242 """Whether all acceptance criteria were met."""

243

244 unmet_criteria: Sequence[UnmetCriterionProtocol]

245 """List of criteria that were not satisfied."""

246

247 confidence: float

248 """Model confidence in the verdict (0.0 to 1.0)."""

249

250 reasoning: str

251 """Explanation of the verification outcome."""

252

253

254@runtime_checkable

255 class DeadlockInfoProtocol(Protocol):

256 """Protocol for deadlock detection information.

257

258 Matches the shape of domain.deadlock.DeadlockInfo for structural typing.

259 """

260

261 cycle: list[str]

262 """List of agent IDs forming the deadlock cycle."""

263

264 victim_id: str

265 """Agent ID selected to be killed (youngest in cycle)."""

266

267 victim_issue_id: str | None

268 """Issue ID the victim was working on."""

269

270 blocked_on: str

271 """Lock path the victim was waiting for."""

272

273 blocker_id: str

274 """Agent ID holding the lock the victim needs."""

275

276 blocker_issue_id: str | None

277 """Issue ID the blocker was working on."""

278

279

280@runtime_checkable

281 class LockEventProtocol(Protocol):

282 """Protocol for lock events.

283

284 Matches the shape of core.models.LockEvent for structural typing.

285 """

286

287 event_type: Any

288 """Type of lock event (LockEventType enum value)."""

289

290 agent_id: str

291 """ID of the agent that emitted this event."""

292

293 lock_path: str

294 """Path to the lock file."""

295

296 timestamp: float

297 """Unix timestamp when the event occurred."""

298

299

300@runtime_checkable

301 class DeadlockMonitorProtocol(Protocol):

302 """Protocol for deadlock monitor.

303

304 Matches the interface of domain.deadlock.DeadlockMonitor for structural typing.

305 Only includes the handle_event method used by hooks.

306 """

307

308 async def handle_event(self, event: Any) -> Any: # noqa: ANN401

309 """Process a lock event and check for deadlocks.

310

311 Args:

312 event: The lock event to process (LockEvent).

313

314 Returns:

315 DeadlockInfo if a deadlock is detected, None otherwise.

316 """

317 ...

318

319

320@runtime_checkable

321 class LogProvider(Protocol):

322 """Protocol for abstracting SDK log storage and schema.

323

324 Provides methods for accessing session logs without hardcoding filesystem

325 paths or Claude SDK's internal log format. This enables:

326 - Testing with mock log providers that return synthetic events

327 - Future support for remote log storage or SDK API access

328 - Isolation from SDK log format changes

329

330 The canonical implementation is FileSystemLogProvider, which reads JSONL

331 logs from the Claude SDK's ~/.claude/projects/{encoded-path}/ directory.

332 Test implementations can return in-memory events for isolation.

333

334 Methods:

335 get_log_path: Get the filesystem path for a session log.

336 iter_events: Iterate over typed log entries from a session.

337 get_end_offset: Get the byte offset at the end of a log file.

338 """

339

340 def get_log_path(self, repo_path: Path, session_id: str) -> Path:

341 """Get the filesystem path for a session's log file.

342

343 This method computes the expected log file path based on the repo

344 and session. The path may or may not exist yet.

345

346 Args:

347 repo_path: Path to the repository the session was run in.

348 session_id: Claude SDK session ID (UUID from ResultMessage).

349

350 Returns:

351 Path to the JSONL log file.

352 """

353 ...

354

355 def iter_events(

356 self, log_path: Path, offset: int = 0

357 ) -> Iterator[JsonlEntryProtocol]:

358 """Iterate over parsed JSONL entries from a log file.

359

360 Reads the file starting from the given byte offset and yields

361 structured entries. This enables incremental parsing across

362 retry attempts.

363

364 Args:

365 log_path: Path to the JSONL log file.

366 offset: Byte offset to start reading from (default 0).

367

368 Yields:

369 JsonlEntryProtocol objects for each successfully parsed JSON line.

370 The entry field contains the typed LogEntry if parsing succeeded.

371

372 Note:

373 - Lines that fail UTF-8 decoding are silently skipped

374 - Empty lines are silently skipped

375 - Lines that fail JSON parsing are silently skipped

376 - If file doesn't exist, yields nothing

377 """

378 ...

379

380 def get_end_offset(self, log_path: Path, start_offset: int = 0) -> int:

381 """Get the byte offset at the end of a log file.

382

383 This is a lightweight method for getting the current file position.

384 Use this when you only need the offset for retry scoping, not the

385 parsed entries themselves.

386

387 Args:

388 log_path: Path to the JSONL log file.

389 start_offset: Byte offset to start from (default 0).

390

391 Returns:

392 The byte offset at the end of the file, or start_offset if file

393 doesn't exist or can't be read.

394 """

395 ...

396

397 def extract_bash_commands(self, entry: JsonlEntryProtocol) -> list[tuple[str, str]]:

398 """Extract Bash tool_use commands from an entry.

399

400 Args:

401 entry: A JsonlEntryProtocol from iter_events.

402

403 Returns:

404 List of (tool_id, command) tuples for Bash tool_use blocks.

405 Returns empty list if entry is not an assistant message.

406 """

407 ...

408

409 def extract_tool_results(self, entry: JsonlEntryProtocol) -> list[tuple[str, bool]]:

410 """Extract tool_result entries from an entry.

411

412 Args:

413 entry: A JsonlEntryProtocol from iter_events.

414

415 Returns:

416 List of (tool_use_id, is_error) tuples for tool_result blocks.

417 Returns empty list if entry is not a user message.

418 """

419 ...

420

421 def extract_assistant_text_blocks(self, entry: JsonlEntryProtocol) -> list[str]:

422 """Extract text content from assistant message blocks.

423

424 Args:

425 entry: A JsonlEntryProtocol from iter_events.

426

427 Returns:

428 List of text strings from text blocks in assistant messages.

429 Returns empty list if entry is not an assistant message.

430 """

431 ...

432

433

434@runtime_checkable

435 class IssueProvider(Protocol):

436 """Protocol for issue tracking operations.

437

438 Provides methods for fetching, claiming, closing, and marking issues.

439 The orchestrator uses this to manage issue lifecycle during parallel

440 processing.

441

442 The canonical implementation is BeadsClient, which wraps the bd CLI.

443 Test implementations can use in-memory state for isolation.

444

445 Methods match BeadsClient's async API exactly so BeadsClient conforms

446 to this protocol without adaptation.

447 """

448

449 async def get_ready_async(

450 self,

451 exclude_ids: set[str] | None = None,

452 epic_id: str | None = None,

453 only_ids: set[str] | None = None,

454 suppress_warn_ids: set[str] | None = None,

455 prioritize_wip: bool = False,

456 focus: bool = True,

457 orphans_only: bool = False,

458 ) -> list[str]:

459 """Get list of ready issue IDs, sorted by priority.

460

461 Args:

462 exclude_ids: Set of issue IDs to exclude from results.

463 epic_id: Optional epic ID to filter by - only return children.

464 only_ids: Optional set of issue IDs to include exclusively.

465 suppress_warn_ids: Set of issue IDs to suppress from warnings.

466 prioritize_wip: If True, sort in_progress issues first.

467 focus: If True, group tasks by parent epic.

468 orphans_only: If True, only return issues with no parent epic.

469

470 Returns:

471 List of issue IDs sorted by priority (lower = higher priority).

472 """

473 ...

474

475 async def claim_async(self, issue_id: str) -> bool:

476 """Claim an issue by setting status to in_progress.

477

478 Args:

479 issue_id: The issue ID to claim.

480

481 Returns:

482 True if successfully claimed, False otherwise.

483 """

484 ...

485

486 async def close_async(self, issue_id: str) -> bool:

487 """Close an issue by setting status to closed.

488

489 Args:

490 issue_id: The issue ID to close.

491

492 Returns:

493 True if successfully closed, False otherwise.

494 """

495 ...

496

497 async def mark_needs_followup_async(

498 self, issue_id: str, reason: str, log_path: Path | None = None

499 ) -> bool:

500 """Mark an issue as needing follow-up.

501

502 Called when the quality gate fails and the issue needs manual

503 intervention or a follow-up task.

504

505 Args:

506 issue_id: The issue ID to mark.

507 reason: Description of why the quality gate failed.

508 log_path: Optional path to the JSONL log file from the attempt.

509

510 Returns:

511 True if successfully marked, False otherwise.

512 """

513 ...

514

515 async def add_dependency_async(self, issue_id: str, depends_on_id: str) -> bool:

516 """Add a dependency between two issues.

517

518 Creates a "blocks" relationship where depends_on_id blocks issue_id.

519 Used by deadlock resolution to record that a victim issue depends on

520 the blocker's issue.

521

522 Args:

523 issue_id: The issue that depends on another.

524 depends_on_id: The issue that blocks issue_id.

525

526 Returns:

527 True if dependency added successfully, False otherwise.

528 """

529 ...

530

531 async def get_issue_description_async(self, issue_id: str) -> str | None:

532 """Get the description of an issue.

533

534 Args:

535 issue_id: The issue ID to get description for.

536

537 Returns:

538 The issue description string, or None if not found.

539 """

540 ...

541

542 async def close_eligible_epics_async(self) -> bool:

543 """Auto-close epics where all children are complete.

544

545 Returns:

546 True if any epics were closed, False otherwise.

547 """

548 ...

549

550 async def commit_issues_async(self) -> bool:

551 """Commit .beads/issues.jsonl if it has changes.

552

553 Returns:

554 True if commit succeeded, False otherwise.

555 """

556 ...

557

558 async def reset_async(

559 self, issue_id: str, log_path: Path | None = None, error: str | None = None

560 ) -> bool:

561 """Reset an issue back to ready status.

562

563 Called when an implementation attempt fails and the issue should be

564 made available for retry.

565

566 Args:

567 issue_id: The issue ID to reset.

568 log_path: Optional path to the JSONL log file from the attempt.

569 error: Optional error message describing the failure.

570

571 Returns:

572 True if successfully reset, False otherwise.

573 """

574 ...

575

576 async def get_epic_children_async(self, epic_id: str) -> set[str]:

577 """Get all child issue IDs of an epic.

578

579 Args:

580 epic_id: The epic ID to get children for.

581

582 Returns:

583 Set of child issue IDs, or empty set if not found or on error.

584 """

585 ...

586

587 async def get_parent_epic_async(self, issue_id: str) -> str | None:

588 """Get the parent epic ID for an issue.

589

590 Args:

591 issue_id: The issue ID to find the parent epic for.

592

593 Returns:

594 The parent epic ID, or None if no parent epic exists (orphan).

595 """

596 ...

597

598 async def create_issue_async(

599 self,

600 title: str,

601 description: str,

602 priority: str,

603 tags: list[str] | None = None,

604 parent_id: str | None = None,

605 ) -> str | None:

606 """Create a new issue for tracking.

607

608 Used to create tracking issues for low-priority review findings (P2/P3)

609 that should be addressed later but don't block the current work.

610

611 Args:

612 title: Issue title.

613 description: Issue description (supports markdown).

614 priority: Priority string (P1, P2, P3, etc.).

615 tags: Optional list of tags to apply.

616 parent_id: Optional parent epic ID to attach this issue to.

617

618 Returns:

619 Created issue ID, or None on failure.

620 """

621 ...

622

623 async def find_issue_by_tag_async(self, tag: str) -> str | None:

624 """Find an existing issue with the given tag.

625

626 Used for deduplication when creating tracking issues.

627

628 Args:

629 tag: The tag to search for.

630

631 Returns:

632 Issue ID if found, None otherwise.

633 """

634 ...

635

636 async def update_issue_description_async(

637 self, issue_id: str, description: str

638 ) -> bool:

639 """Update an issue's description.

640

641 Used for appending new findings to existing tracking issues.

642

643 Args:

644 issue_id: The issue ID to update.

645 description: New description content (replaces existing).

646

647 Returns:

648 True if successfully updated, False otherwise.

649 """

650 ...

651

652 async def update_issue_async(

653 self,

654 issue_id: str,

655 *,

656 title: str | None = None,

657 priority: str | None = None,

658 ) -> bool:

659 """Update an issue's title and/or priority.

660

661 Used for updating tracking issues when new findings change

662 the count or highest priority.

663

664 Args:

665 issue_id: The issue ID to update.

666 title: New title (optional).

667 priority: New priority string like "P2" (optional).

668

669 Returns:

670 True if successfully updated, False otherwise.

671 """

672 ...

673

674

675@runtime_checkable

676 class CodeReviewer(Protocol):

677 """Protocol for code review operations.

678

679 Provides a callable interface for reviewing commits and returning

680 structured results. The orchestrator uses this to run post-commit

681 code reviews via the Cerberus review-gate CLI.

682

683 The canonical implementation is DefaultReviewer in cerberus_review.py.

684 Test implementations can return predetermined results for isolation.

685 """

686

687 async def __call__(

688 self,

689 diff_range: str,

690 context_file: Path | None = None,

691 timeout: int = 300,

692 claude_session_id: str | None = None,

693 *,

694 commit_shas: Sequence[str] | None = None,

695 ) -> ReviewResultProtocol:

696 """Run code review on a diff range.

697

698 Args:

699 diff_range: Git diff range to review (e.g., "baseline..HEAD").

700 context_file: Optional path to file with issue description context.

701 timeout: Timeout in seconds for the review operation.

702 claude_session_id: Optional Claude session ID for review attribution.

703 commit_shas: Optional list of commit SHAs to review directly.

704 When provided, reviewers should scope to these commits only.

705

706 Returns:

707 ReviewResultProtocol with review outcome. On parse failure,

708 returns passed=False with parse_error set.

709 """

710 ...

711

712

713@runtime_checkable

714 class GateChecker(Protocol):

715 """Protocol for quality gate checking.

716

717 Provides methods for verifying agent work meets quality requirements.

718 The orchestrator uses this after each agent attempt to determine if

719 the issue was successfully resolved.

720

721 The canonical implementation is QualityGate, which conforms to this

722 protocol. Test implementations can verify specific conditions for isolation.

723

724 Methods match QualityGate's API exactly so QualityGate conforms to this

725 protocol without adaptation.

726 """

727

728 def check_with_resolution(

729 self,

730 issue_id: str,

731 log_path: Path,

732 baseline_timestamp: int | None = None,

733 log_offset: int = 0,

734 spec: ValidationSpecProtocol | None = None,

735 ) -> GateResultProtocol:

736 """Run quality gate check with support for no-op/obsolete resolutions.

737

738 This method is scope-aware and handles special resolution outcomes:

739 - ISSUE_NO_CHANGE: Issue already addressed, no commit needed

740 - ISSUE_OBSOLETE: Issue no longer relevant, no commit needed

741 - ISSUE_ALREADY_COMPLETE: Work done in previous run

742

743 Args:

744 issue_id: The issue ID to verify.

745 log_path: Path to the JSONL log file from agent session.

746 baseline_timestamp: Unix timestamp for commit freshness check.

747 log_offset: Byte offset to start parsing from.

748 spec: ValidationSpec for scope-aware evidence checking (required).

749

750 Returns:

751 GateResultProtocol with pass/fail, failure reasons, and resolution.

752

753 Raises:

754 ValueError: If spec is not provided.

755 """

756 ...

757

758 def get_log_end_offset(self, log_path: Path, start_offset: int = 0) -> int:

759 """Get the byte offset at the end of a log file.

760

761 This is a lightweight method for getting the current file position

762 after reading from a given offset. Use this when you only need the

763 offset for retry scoping, not the evidence itself.

764

765 Args:

766 log_path: Path to the JSONL log file.

767 start_offset: Byte offset to start from (default 0).

768

769 Returns:

770 The byte offset at the end of the file, or start_offset if file

771 doesn't exist or can't be read.

772 """

773 ...

774

775 def check_no_progress(

776 self,

777 log_path: Path,

778 log_offset: int,

779 previous_commit_hash: str | None,

780 current_commit_hash: str | None,

781 spec: ValidationSpecProtocol | None = None,

782 check_validation_evidence: bool = True,

783 ) -> bool:

784 """Check if no progress was made since the last attempt.

785

786 No progress is detected when ALL of these are true:

787 - The commit hash hasn't changed (or both are None)

788 - No uncommitted changes in the working tree

789 - (Optionally) No new validation evidence was found after the log offset

790

791 Args:

792 log_path: Path to the JSONL log file from agent session.

793 log_offset: Byte offset marking the end of the previous attempt.

794 previous_commit_hash: Commit hash from the previous attempt.

795 current_commit_hash: Commit hash from this attempt.

796 spec: Optional ValidationSpec for spec-driven evidence detection.

797 check_validation_evidence: If True (default), also check for new validation

798 evidence. Set to False for review retries where only commit/working-tree

799 changes should gate progress.

800

801 Returns:

802 True if no progress was made, False if progress was detected.

803 """

804 ...

805

806 def parse_validation_evidence_with_spec(

807 self, log_path: Path, spec: ValidationSpecProtocol, offset: int = 0

808 ) -> ValidationEvidenceProtocol:

809 """Parse JSONL log for validation evidence using spec-defined patterns.

810

811 Args:

812 log_path: Path to the JSONL log file.

813 spec: ValidationSpec defining detection patterns.

814 offset: Byte offset to start parsing from (default 0).

815

816 Returns:

817 ValidationEvidenceProtocol with flags indicating which validations ran.

818 """

819 ...

820

821 def check_commit_exists(

822 self, issue_id: str, baseline_timestamp: int | None = None

823 ) -> CommitResultProtocol:

824 """Check if a commit with bd-<issue_id> exists in recent history.

825

826 Searches commits from the last 30 days to accommodate long-running

827 work that may span multiple days.

828

829 Args:

830 issue_id: The issue ID to search for (without bd- prefix).

831 baseline_timestamp: Unix timestamp. If provided, only accepts

832 commits created after this time.

833

834 Returns:

835 CommitResultProtocol indicating whether a matching commit exists.

836 """

837 ...

838

839

840@runtime_checkable

841 class EpicVerificationModel(Protocol):

842 """Protocol for model-agnostic epic verification.

843

844 Provides an interface for verifying whether code changes satisfy

845 an epic's acceptance criteria. The initial implementation uses

846 Claude via SDK, but this protocol allows swapping to other models

847 (Codex, Gemini, local models) without changing the verifier.

848

849 The canonical implementation is ClaudeEpicVerificationModel in

850 src/epic_verifier.py. Test implementations can return predetermined

851 verdicts for isolation.

852 """

853

854 async def verify(

855 self,

856 epic_criteria: str,

857 commit_range: str,

858 commit_list: str,

859 spec_content: str | None,

860 ) -> EpicVerdictProtocol:

861 """Verify if the commit scope satisfies the epic's acceptance criteria.

862

863 Args:

864 epic_criteria: The epic's acceptance criteria text.

865 commit_range: Commit range hint covering child issue commits.

866 commit_list: Authoritative list of commit SHAs to inspect.

867 spec_content: Optional content of linked spec file.

868

869 Returns:

870 Structured verdict with pass/fail and unmet criteria details.

871 """

872 ...

873

874

875# =============================================================================

876# SDK Client Protocol

877# =============================================================================

878

879

880@runtime_checkable

881 class SDKClientProtocol(Protocol):

882 """Protocol for Claude SDK client abstraction.

883

884 Enables the pipeline layer to use SDK clients without importing

885 claude_agent_sdk directly. The canonical implementation is

886 ClaudeSDKClient, wrapped by SDKClientFactory in infra.

887

888 This protocol captures the async context manager and streaming

889 interface used by AgentSessionRunner.

890 """

891

892 async def __aenter__(self) -> Self:

893 """Enter async context."""

894 ...

895

896 async def __aexit__(

897 self,

898 exc_type: type[BaseException] | None,

899 exc_val: BaseException | None,

900 exc_tb: TracebackType | None,

901 ) -> None:

902 """Exit async context."""

903 ...

904

905 async def query(self, prompt: str, session_id: str | None = None) -> None:

906 """Send a query to the agent.

907

908 Args:

909 prompt: The prompt text to send.

910 session_id: Optional session ID for continuation.

911 """

912 ...

913

914 def receive_response(self) -> AsyncIterator[object]:

915 """Get an async iterator of response messages.

916

917 Returns:

918 AsyncIterator yielding AssistantMessage, ResultMessage, etc.

919 """

920 ...

921

922 async def disconnect(self) -> None:

923 """Disconnect the client."""

924 ...

925

926

927@runtime_checkable

928 class SDKClientFactoryProtocol(Protocol):

929 """Protocol for SDK client factory.

930

931 Enables dependency injection of the factory into pipeline components,

932 allowing tests to provide mock factories.

933 """

934

935 def create(self, options: object) -> SDKClientProtocol:

936 """Create a new SDK client with the given options.

937

938 Args:

939 options: ClaudeAgentOptions for the client.

940

941 Returns:

942 SDKClientProtocol instance.

943 """

944 ...

945

946 def create_options(

947 self,

948 *,

949 cwd: str,

950 permission_mode: str = "bypassPermissions",

951 model: str = "opus",

952 system_prompt: dict[str, str] | None = None,

953 setting_sources: list[str] | None = None,

954 mcp_servers: object | None = None,

955 disallowed_tools: list[str] | None = None,

956 env: dict[str, str] | None = None,

957 hooks: dict[str, list[object]] | None = None,

958 ) -> object:

959 """Create SDK options without requiring SDK import in caller.

960

961 Args:

962 cwd: Working directory for the agent.

963 permission_mode: Permission mode.

964 model: Model to use.

965 system_prompt: System prompt configuration.

966 setting_sources: List of setting sources.

967 mcp_servers: List of MCP server configurations.

968 disallowed_tools: List of tools to disallow.

969 env: Environment variables for the agent.

970 hooks: Hook configurations keyed by event type.

971

972 Returns:

973 ClaudeAgentOptions instance.

974 """

975 ...

976

977 def create_hook_matcher(

978 self,

979 matcher: object | None,

980 hooks: list[object],

981 ) -> object:

982 """Create a HookMatcher for SDK hook registration.

983

984 Args:

985 matcher: Optional matcher configuration.

986 hooks: List of hook callables.

987

988 Returns:

989 HookMatcher instance.

990 """

991 ...

992

993

994# =============================================================================

995# Command Runner Protocols

996# =============================================================================

997

998

999@runtime_checkable

1000 class CommandResultProtocol(Protocol):

1001 """Protocol for command execution results.

1002

1003 Matches the interface of src.infra.tools.command_runner.CommandResult

1004 for structural typing without import-time dependencies.

1005 """

1006

1007 ok: bool

1008 returncode: int

1009 stdout: str

1010 stderr: str

1011 timed_out: bool

1012 duration_seconds: float

1013

1014 def stdout_tail(self, max_chars: int = 800, max_lines: int = 20) -> str:

1015 """Get truncated stdout.

1016

1017 Args:

1018 max_chars: Maximum number of characters.

1019 max_lines: Maximum number of lines.

1020

1021 Returns:

1022 Truncated stdout string.

1023 """

1024 ...

1025

1026 def stderr_tail(self, max_chars: int = 800, max_lines: int = 20) -> str:

1027 """Get truncated stderr.

1028

1029 Args:

1030 max_chars: Maximum number of characters.

1031 max_lines: Maximum number of lines.

1032

1033 Returns:

1034 Truncated stderr string.

1035 """

1036 ...

1037

1038

1039@runtime_checkable

1040 class CommandRunnerPort(Protocol):

1041 """Protocol for abstracting command execution.

1042

1043 Enables dependency injection of command runners into domain modules,

1044 allowing the core layer to define the interface without depending on

1045 the infrastructure implementation.

1046

1047 The canonical implementation is CommandRunner in

1048 src/infra/tools/command_runner.py.

1049 """

1050

1051 def run(

1052 self,

1053 cmd: list[str] | str,

1054 env: Mapping[str, str] | None = None,

1055 timeout: float | None = None,

1056 use_process_group: bool | None = None,

1057 shell: bool = False,

1058 cwd: Path | None = None,

1059 ) -> CommandResultProtocol:

1060 """Run a command synchronously.

1061

1062 Args:

1063 cmd: Command to run. Can be a list of strings or a shell string.

1064 env: Environment variables to set (merged with os.environ).

1065 timeout: Timeout for command execution in seconds.

1066 use_process_group: Whether to use process group for termination.

1067 shell: If True, run command through shell.

1068 cwd: Override working directory for this command.

1069

1070 Returns:

1071 CommandResultProtocol with execution details.

1072 """

1073 ...

1074

1075 async def run_async(

1076 self,

1077 cmd: list[str] | str,

1078 env: Mapping[str, str] | None = None,

1079 timeout: float | None = None,

1080 use_process_group: bool | None = None,

1081 shell: bool = False,

1082 cwd: Path | None = None,

1083 ) -> CommandResultProtocol:

1084 """Run a command asynchronously.

1085

1086 Args:

1087 cmd: Command to run. Can be a list of strings or a shell string.

1088 env: Environment variables to set (merged with os.environ).

1089 timeout: Timeout for command execution in seconds.

1090 use_process_group: Whether to use process group for termination.

1091 shell: If True, run command through shell.

1092 cwd: Override working directory for this command.

1093

1094 Returns:

1095 CommandResultProtocol with execution details.

1096 """

1097 ...

1098

1099

1100@runtime_checkable

1101 class EnvConfigPort(Protocol):

1102 """Protocol for abstracting environment configuration.

1103

1104 Enables dependency injection of environment config into domain modules,

1105 allowing the core layer to define the interface without depending on

1106 the infrastructure implementation.

1107

1108 The canonical implementation is in src/infra/tools/env.py.

1109 """

1110

1111 @property

1112 def scripts_dir(self) -> Path:

1113 """Path to the scripts directory (e.g., test-mutex.sh)."""

1114 ...

1115

1116 @property

1117 def cache_dir(self) -> Path:

1118 """Path to the mala cache directory."""

1119 ...

1120

1121 @property

1122 def lock_dir(self) -> Path:

1123 """Path to the lock directory for multi-agent coordination."""

1124 ...

1125

1126 def find_cerberus_bin_path(self) -> Path | None:

1127 """Find the cerberus plugin bin directory.

1128

1129 Returns:

1130 Path to cerberus bin directory, or None if not found.

1131 """

1132 ...

1133

1134

1135@runtime_checkable

1136 class LockManagerPort(Protocol):

1137 """Protocol for abstracting file-based locking operations.

1138

1139 Enables dependency injection of lock managers into domain modules,

1140 allowing the core layer to define the interface without depending on

1141 the infrastructure implementation.

1142

1143 The canonical implementation is in src/infra/tools/locking.py.

1144 """

1145

1146 def lock_path(self, filepath: str, repo_namespace: str | None = None) -> Path:

1147 """Get the lock file path for a given filepath.

1148

1149 Args:

1150 filepath: Path to the file to lock.

1151 repo_namespace: Optional repo namespace for cross-repo disambiguation.

1152

1153 Returns:

1154 Path to the lock file.

1155 """

1156 ...

1157

1158 def try_lock(

1159 self, filepath: str, agent_id: str, repo_namespace: str | None = None

1160 ) -> bool:

1161 """Try to acquire a lock without blocking.

1162

1163 Args:

1164 filepath: Path to the file to lock.

1165 agent_id: Identifier of the agent requesting the lock.

1166 repo_namespace: Optional repo namespace for cross-repo disambiguation.

1167

1168 Returns:

1169 True if lock was acquired, False if already held by another agent.

1170 """

1171 ...

1172

1173 def wait_for_lock(

1174 self,

1175 filepath: str,

1176 agent_id: str,

1177 repo_namespace: str | None = None,

1178 timeout_seconds: float = 30.0,

1179 poll_interval_ms: int = 100,

1180 ) -> bool:

1181 """Wait for and acquire a lock on a file.

1182

1183 Args:

1184 filepath: Path to the file to lock.

1185 agent_id: Identifier of the agent requesting the lock.

1186 repo_namespace: Optional repo namespace for cross-repo disambiguation.

1187 timeout_seconds: Maximum time to wait for the lock in seconds.

1188 poll_interval_ms: Polling interval in milliseconds.

1189

1190 Returns:

1191 True if lock was acquired, False if timeout.

1192 """

1193 ...

1194

1195 def release_lock(

1196 self, filepath: str, agent_id: str, repo_namespace: str | None = None

1197 ) -> bool:

1198 """Release a lock on a file.

1199

1200 Only releases the lock if it is held by the specified agent_id.

1201 This prevents accidental or malicious release of locks held by

1202 other agents.

1203

1204 Args:

1205 filepath: Path to the file to unlock.

1206 agent_id: Identifier of the agent releasing the lock.

1207 repo_namespace: Optional repo namespace for cross-repo disambiguation.

1208

1209 Returns:

1210 True if lock was released, False if lock was not held by agent_id.

1211 """

1212 ...

1213

1214

1215@runtime_checkable

1216 class LoggerPort(Protocol):

1217 """Protocol for console/terminal logging with colored output.

1218

1219 Enables dependency injection of loggers into domain modules,

1220 allowing the core layer to define the interface without depending on

1221 the infrastructure implementation.

1222

1223 The canonical implementation is in src/infra/io/log_output/console.py.

1224 """

1225

1226 def log(

1227 self,

1228 message: str,

1229 *,

1230 level: str = "info",

1231 color: str | None = None,

1232 ) -> None:

1233 """Log a message to the console.

1234

1235 Args:

1236 message: The message to log.

1237 level: Log level (e.g., "info", "debug", "error").

1238 color: Optional color name (e.g., "cyan", "green", "red").

1239 """

1240 ...

1241

1242

1243# =============================================================================

1244# Event Sink Protocol

1245# =============================================================================

1246

1247

1248@dataclass

1249 class EventRunConfig:

1250 """Configuration snapshot for a run, passed to on_run_started.

1251

1252 Mirrors the relevant fields from MalaOrchestrator for event reporting.

1253 """

1254

1255 repo_path: str

1256 max_agents: int | None

1257 timeout_minutes: int | None

1258 max_issues: int | None

1259 max_gate_retries: int

1260 max_review_retries: int

1261 epic_id: str | None = None

1262 only_ids: list[str] | None = None

1263 braintrust_enabled: bool = False

1264 braintrust_disabled_reason: str | None = None # e.g., "add BRAINTRUST_API_KEY..."

1265 review_enabled: bool = True # Cerberus code review enabled

1266 review_disabled_reason: str | None = None

1267 prioritize_wip: bool = False

1268 orphans_only: bool = False

1269 cli_args: dict[str, object] | None = None

1270

1271

1272@runtime_checkable

1273 class MalaEventSink(Protocol):

1274 """Protocol for receiving orchestrator events.

1275

1276 Implementations handle presentation (console, logging, metrics) while

1277 the orchestrator focuses on coordination logic. Each method corresponds

1278 to a semantic event in the orchestration flow.

1279

1280 All methods are synchronous and should be non-blocking. Implementations

1281 that need async behavior should queue events internally.

1282 """

1283

1284 # -------------------------------------------------------------------------

1285 # Run lifecycle

1286 # -------------------------------------------------------------------------

1287

1288 def on_run_started(self, config: EventRunConfig) -> None:

1289 """Called when the orchestrator run begins.

1290

1291 Args:

1292 config: Run configuration snapshot.

1293 """

1294 ...

1295

1296 def on_run_completed(

1297 self,

1298 success_count: int,

1299 total_count: int,

1300 run_validation_passed: bool,

1301 abort_reason: str | None = None,

1302 ) -> None:

1303 """Called when the orchestrator run completes.

1304

1305 Args:

1306 success_count: Number of issues completed successfully.

1307 total_count: Total number of issues processed.

1308 run_validation_passed: Whether Gate 4 (run-level validation) passed.

1309 abort_reason: If run was aborted, the reason string.

1310 """

1311 ...

1312

1313 def on_ready_issues(self, issue_ids: list[str]) -> None:

1314 """Called when ready issues are fetched.

1315

1316 Args:

1317 issue_ids: List of issue IDs ready for processing.

1318 """

1319 ...

1320

1321 def on_waiting_for_agents(self, count: int) -> None:

1322 """Called when waiting for agents to complete.

1323

1324 Args:

1325 count: Number of active agents being waited on.

1326 """

1327 ...

1328

1329 def on_no_more_issues(self, reason: str) -> None:

1330 """Called when there are no more issues to process.

1331

1332 Args:

1333 reason: Reason string (e.g., "limit_reached", "none_ready").

1334 """

1335 ...

1336

1337 # -------------------------------------------------------------------------

1338 # Agent lifecycle

1339 # -------------------------------------------------------------------------

1340

1341 def on_agent_started(self, agent_id: str, issue_id: str) -> None:

1342 """Called when an agent is spawned for an issue.

1343

1344 Args:

1345 agent_id: Unique agent identifier.

1346 issue_id: Issue being worked on.

1347 """

1348 ...

1349

1350 def on_agent_completed(

1351 self,

1352 agent_id: str,

1353 issue_id: str,

1354 success: bool,

1355 duration_seconds: float,

1356 summary: str,

1357 ) -> None:

1358 """Called when an agent completes (success or failure).

1359

1360 Args:

1361 agent_id: Unique agent identifier.

1362 issue_id: Issue that was worked on.

1363 success: Whether the agent succeeded.

1364 duration_seconds: Total execution time.

1365 summary: Result summary or error message.

1366 """

1367 ...

1368

1369 def on_claim_failed(self, agent_id: str, issue_id: str) -> None:

1370 """Called when claiming an issue fails.

1371

1372 Args:

1373 agent_id: Agent that attempted the claim.

1374 issue_id: Issue that could not be claimed.

1375 """

1376 ...

1377

1378 # -------------------------------------------------------------------------

1379 # SDK message streaming

1380 # -------------------------------------------------------------------------

1381

1382 def on_tool_use(

1383 self,

1384 agent_id: str,

1385 tool_name: str,

1386 description: str = "",

1387 arguments: dict[str, Any] | None = None,

1388 ) -> None:

1389 """Called when an agent invokes a tool.

1390

1391 Args:

1392 agent_id: Agent invoking the tool.

1393 tool_name: Name of the tool being called.

1394 description: Brief description of the action.

1395 arguments: Tool arguments (may be truncated for display).

1396 """

1397 ...

1398

1399 def on_agent_text(self, agent_id: str, text: str) -> None:

1400 """Called when an agent emits text output.

1401

1402 Args:

1403 agent_id: Agent emitting text.

1404 text: Text content (may be truncated for display).

1405 """

1406 ...

1407

1408 # -------------------------------------------------------------------------

1409 # Quality gate events

1410 # -------------------------------------------------------------------------

1411

1412 def on_gate_started(

1413 self,

1414 agent_id: str | None,

1415 attempt: int,

1416 max_attempts: int,

1417 issue_id: str | None = None,

1418 ) -> None:

1419 """Called when a quality gate check begins.

1420

1421 Args:

1422 agent_id: Agent ID (None for run-level gate).

1423 attempt: Current attempt number (1-indexed).

1424 max_attempts: Maximum retry attempts.

1425 issue_id: Issue being validated (for display).

1426 """

1427 ...

1428

1429 def on_gate_passed(

1430 self,

1431 agent_id: str | None,

1432 issue_id: str | None = None,

1433 ) -> None:

1434 """Called when a quality gate passes.

1435

1436 Args:

1437 agent_id: Agent ID (None for run-level gate).

1438 issue_id: Issue being validated (for display).

1439 """

1440 ...

1441

1442 def on_gate_failed(

1443 self,

1444 agent_id: str | None,

1445 attempt: int,

1446 max_attempts: int,

1447 issue_id: str | None = None,

1448 ) -> None:

1449 """Called when a quality gate fails after all retries.

1450

1451 Args:

1452 agent_id: Agent ID (None for run-level gate).

1453 attempt: Final attempt number.

1454 max_attempts: Maximum retry attempts.

1455 issue_id: Issue being validated (for display).

1456 """

1457 ...

1458

1459 def on_gate_retry(

1460 self,

1461 agent_id: str,

1462 attempt: int,

1463 max_attempts: int,

1464 issue_id: str | None = None,

1465 ) -> None:

1466 """Called when retrying a quality gate after failure.

1467

1468 Args:

1469 agent_id: Agent being retried.

1470 attempt: Current retry attempt number.

1471 max_attempts: Maximum retry attempts.

1472 issue_id: Issue being validated (for display).

1473 """

1474 ...

1475

1476 def on_gate_result(

1477 self,

1478 agent_id: str | None,

1479 passed: bool,

1480 failure_reasons: list[str] | None = None,

1481 issue_id: str | None = None,

1482 ) -> None:

1483 """Called when a quality gate check completes with its result.

1484

1485 This provides the detailed gate result including failure reasons,

1486 complementing the simpler on_gate_passed/on_gate_failed events.

1487

1488 Args:

1489 agent_id: Agent ID (None for run-level gate).

1490 passed: Whether the gate passed.

1491 failure_reasons: List of failure reasons (if failed).

1492 issue_id: Issue being validated (for display).

1493 """

1494 ...

1495

1496 # -------------------------------------------------------------------------

1497 # Codex review events

1498 # -------------------------------------------------------------------------

1499

1500 def on_review_started(

1501 self,

1502 agent_id: str,

1503 attempt: int,

1504 max_attempts: int,

1505 issue_id: str | None = None,

1506 ) -> None:

1507 """Called when a Codex review begins.

1508

1509 Args:

1510 agent_id: Agent being reviewed.

1511 attempt: Current attempt number.

1512 max_attempts: Maximum review attempts.

1513 issue_id: Issue being reviewed (for display).

1514 """

1515 ...

1516

1517 def on_review_passed(

1518 self,

1519 agent_id: str,

1520 issue_id: str | None = None,

1521 ) -> None:

1522 """Called when a Codex review passes.

1523

1524 Args:

1525 agent_id: Agent that passed review.

1526 issue_id: Issue being reviewed (for display).

1527 """

1528 ...

1529

1530 def on_review_retry(

1531 self,

1532 agent_id: str,

1533 attempt: int,

1534 max_attempts: int,

1535 error_count: int | None = None,

1536 parse_error: str | None = None,

1537 issue_id: str | None = None,

1538 ) -> None:

1539 """Called when retrying a Codex review after issues found.

1540

1541 Args:

1542 agent_id: Agent being retried.

1543 attempt: Current retry attempt number.

1544 max_attempts: Maximum review attempts.

1545 error_count: Number of errors found (if available).

1546 parse_error: Parse error message (if review failed to parse).

1547 issue_id: Issue being reviewed (for display).

1548 """

1549 ...

1550

1551 def on_review_warning(

1552 self,

1553 message: str,

1554 agent_id: str | None = None,

1555 issue_id: str | None = None,

1556 ) -> None:

1557 """Called for review-related warnings (e.g., verdict mismatch).

1558

1559 Args:

1560 message: Warning message.

1561 agent_id: Associated agent (if any).

1562 issue_id: Issue being reviewed (for display).

1563 """

1564 ...

1565

1566 # -------------------------------------------------------------------------

1567 # Fixer agent events

1568 # -------------------------------------------------------------------------

1569

1570 def on_fixer_started(

1571 self,

1572 attempt: int,

1573 max_attempts: int,

1574 ) -> None:

1575 """Called when a fixer agent is spawned.

1576

1577 Args:

1578 attempt: Current fixer attempt number.

1579 max_attempts: Maximum fixer attempts.

1580 """

1581 ...

1582

1583 def on_fixer_completed(self, result: str) -> None:

1584 """Called when a fixer agent completes.

1585

1586 Args:

1587 result: Brief result description.

1588 """

1589 ...

1590

1591 def on_fixer_failed(self, reason: str) -> None:

1592 """Called when a fixer agent fails.

1593

1594 Args:

1595 reason: Failure reason (e.g., "timeout", "error").

1596 """

1597 ...

1598

1599 # -------------------------------------------------------------------------

1600 # Issue lifecycle

1601 # -------------------------------------------------------------------------

1602

1603 def on_issue_closed(self, agent_id: str, issue_id: str) -> None:

1604 """Called when an issue is closed after successful completion.

1605

1606 Args:

1607 agent_id: Agent that completed the issue.

1608 issue_id: Issue that was closed.

1609 """

1610 ...

1611

1612 def on_issue_completed(

1613 self,

1614 agent_id: str,

1615 issue_id: str,

1616 success: bool,

1617 duration_seconds: float,

1618 summary: str,

1619 ) -> None:

1620 """Called when an issue implementation completes (success or failure).

1621

1622 This is the primary issue completion event, distinct from on_agent_completed

1623 which tracks the agent lifecycle. Use this for issue-level tracking.

1624

1625 Args:

1626 agent_id: Agent that worked on the issue.

1627 issue_id: Issue that was completed.

1628 success: Whether the issue was successfully implemented.

1629 duration_seconds: Total time spent on the issue.

1630 summary: Result summary or error message.

1631 """

1632 ...

1633

1634 def on_epic_closed(self, agent_id: str) -> None:

1635 """Called when a parent epic is auto-closed.

1636

1637 Args:

1638 agent_id: Agent that triggered the epic closure.

1639 """

1640 ...

1641

1642 def on_validation_started(

1643 self,

1644 agent_id: str,

1645 issue_id: str | None = None,

1646 ) -> None:

1647 """Called when per-issue validation begins.

1648

1649 Args:

1650 agent_id: Agent being validated.

1651 issue_id: Issue being validated (for display).

1652 """

1653 ...

1654

1655 def on_validation_result(

1656 self,

1657 agent_id: str,

1658 passed: bool,

1659 issue_id: str | None = None,

1660 ) -> None:

1661 """Called when per-issue validation completes.

1662

1663 Args:

1664 agent_id: Agent that was validated.

1665 passed: Whether validation passed.

1666 issue_id: Issue being validated (for display).

1667 """

1668 ...

1669

1670 def on_validation_step_running(

1671 self,

1672 step_name: str,

1673 agent_id: str | None = None,

1674 ) -> None:

1675 """Called when a validation step starts.

1676

1677 Args:

1678 step_name: Name of the validation step (e.g., "ruff", "pytest").

1679 agent_id: Associated agent (if any).

1680 """

1681 ...

1682

1683 def on_validation_step_skipped(

1684 self,

1685 step_name: str,

1686 reason: str,

1687 agent_id: str | None = None,

1688 ) -> None:

1689 """Called when a validation step is skipped.

1690

1691 Args:

1692 step_name: Name of the validation step.

1693 reason: Reason for skipping (e.g., "cache hit", "no changes").

1694 agent_id: Associated agent (if any).

1695 """

1696 ...

1697

1698 def on_validation_step_passed(

1699 self,

1700 step_name: str,

1701 duration_seconds: float,

1702 agent_id: str | None = None,

1703 ) -> None:

1704 """Called when a validation step succeeds.

1705

1706 Args:

1707 step_name: Name of the validation step.

1708 duration_seconds: Time taken to complete the step.

1709 agent_id: Associated agent (if any).

1710 """

1711 ...

1712

1713 def on_validation_step_failed(

1714 self,

1715 step_name: str,

1716 exit_code: int,

1717 agent_id: str | None = None,

1718 ) -> None:

1719 """Called when a validation step fails.

1720

1721 Args:

1722 step_name: Name of the validation step.

1723 exit_code: Exit code from the step.

1724 agent_id: Associated agent (if any).

1725 """

1726 ...

1727

1728 # -------------------------------------------------------------------------

1729 # Warnings and diagnostics

1730 # -------------------------------------------------------------------------

1731

1732 def on_warning(self, message: str, agent_id: str | None = None) -> None:

1733 """Called for warning conditions.

1734

1735 Args:

1736 message: Warning message.

1737 agent_id: Associated agent (if any).

1738 """

1739 ...

1740

1741 def on_log_timeout(self, agent_id: str, log_path: str) -> None:

1742 """Called when waiting for a log file times out.

1743

1744 Args:

1745 agent_id: Agent waiting for the log.

1746 log_path: Path to the missing log file.

1747 """

1748 ...

1749

1750 def on_locks_cleaned(self, agent_id: str, count: int) -> None:

1751 """Called when stale locks are cleaned up for an agent.

1752

1753 Args:

1754 agent_id: Agent whose locks were cleaned.

1755 count: Number of locks cleaned.

1756 """

1757 ...

1758

1759 def on_locks_released(self, count: int) -> None:

1760 """Called when remaining locks are released at run end.

1761

1762 Args:

1763 count: Number of locks released.

1764 """

1765 ...

1766

1767 def on_issues_committed(self) -> None:

1768 """Called when .beads/issues.jsonl is committed."""

1769 ...

1770

1771 def on_run_metadata_saved(self, path: str) -> None:

1772 """Called when run metadata is saved.

1773

1774 Args:

1775 path: Path to the saved metadata file.

1776 """

1777 ...

1778

1779 def on_run_level_validation_disabled(self) -> None:

1780 """Called when run-level validation is disabled."""

1781 ...

1782

1783 def on_abort_requested(self, reason: str) -> None:

1784 """Called when a fatal error triggers a run abort.

1785

1786 Args:

1787 reason: Description of the fatal error.

1788 """

1789 ...

1790

1791 def on_tasks_aborting(self, count: int, reason: str) -> None:

1792 """Called when active tasks are being aborted.

1793

1794 Args:

1795 count: Number of active tasks being aborted.

1796 reason: Reason for the abort.

1797 """

1798 ...

1799

1800 # -------------------------------------------------------------------------

1801 # Epic verification lifecycle

1802 # -------------------------------------------------------------------------

1803

1804 def on_epic_verification_started(self, epic_id: str) -> None:

1805 """Called when epic verification begins.

1806

1807 Args:

1808 epic_id: The epic being verified.

1809 """

1810 ...

1811

1812 def on_epic_verification_passed(self, epic_id: str, confidence: float) -> None:

1813 """Called when epic verification passes.

1814

1815 Args:

1816 epic_id: The epic that passed verification.

1817 confidence: Confidence score (0.0 to 1.0).

1818 """

1819 ...

1820

1821 def on_epic_verification_failed(

1822 self,

1823 epic_id: str,

1824 unmet_count: int,

1825 remediation_ids: list[str],

1826 ) -> None:

1827 """Called when epic verification fails with unmet criteria.

1828

1829 Args:

1830 epic_id: The epic that failed verification.

1831 unmet_count: Number of unmet criteria.

1832 remediation_ids: IDs of created remediation issues.

1833 """

1834 ...

1835

1836 def on_epic_remediation_created(

1837 self,

1838 epic_id: str,

1839 issue_id: str,

1840 criterion: str,

1841 ) -> None:

1842 """Called when a remediation issue is created for an unmet criterion.

1843

1844 Args:

1845 epic_id: The epic the remediation is for.

1846 issue_id: The created issue ID.

1847 criterion: The unmet criterion text (may be truncated).

1848 """

1849 ...

1850

1851 # -------------------------------------------------------------------------

1852 # Pipeline module events

1853 # -------------------------------------------------------------------------

1854

1855 def on_lifecycle_state(self, agent_id: str, state: str) -> None:

1856 """Called when lifecycle state changes (verbose/debug).

1857

1858 Args:

1859 agent_id: Agent whose lifecycle changed.

1860 state: New lifecycle state name.

1861 """

1862 ...

1863

1864 def on_log_waiting(self, agent_id: str) -> None:

1865 """Called when waiting for session log file.

1866

1867 Args:

1868 agent_id: Agent waiting for log.

1869 """

1870 ...

1871

1872 def on_log_ready(self, agent_id: str) -> None:

1873 """Called when session log file is ready.

1874

1875 Args:

1876 agent_id: Agent whose log is ready.

1877 """

1878 ...

1879

1880 def on_review_skipped_no_progress(self, agent_id: str) -> None:

1881 """Called when review is skipped due to no progress.

1882

1883 Args:

1884 agent_id: Agent whose review was skipped.

1885 """

1886 ...

1887

1888 def on_fixer_text(self, attempt: int, text: str) -> None:

1889 """Called when fixer agent emits text output.

1890

1891 Args:

1892 attempt: Current fixer attempt number.

1893 text: Text content.

1894 """

1895 ...

1896

1897 def on_fixer_tool_use(

1898 self,

1899 attempt: int,

1900 tool_name: str,

1901 arguments: dict[str, Any] | None = None,

1902 ) -> None:

1903 """Called when fixer agent invokes a tool.

1904

1905 Args:

1906 attempt: Current fixer attempt number.

1907 tool_name: Name of the tool being called.

1908 arguments: Tool arguments.

1909 """

1910 ...

1911

1912 def on_deadlock_detected(self, info: DeadlockInfoProtocol) -> None:

1913 """Called when a deadlock is detected and resolved.

1914

1915 Args:

1916 info: Information about the detected deadlock, including the cycle

1917 of agents, the victim selected for cancellation, and the

1918 blocker holding the needed resource.

1919 """

1920 ...