Coverage for src/keel/swarm.py: 100%
333 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Keel Swarm — Deterministic static dependency analysis & conflict clustering.
3Pure, stdlib-first dependency graph analysis for multi-agent parallel execution.
4Partitions candidate issues into orthogonal (disjoint) Waves and independent Clusters,
5enabling Direct Batch Landing for non-overlapping diff trees and adaptive merge
6funneling with automated conflict recovery for dependent trees.
8All functions here are pure and deterministic — no subprocess, no network.
9"""
11from __future__ import annotations
13import datetime
14import fnmatch
15import json
16import posixpath
17import re
18from dataclasses import dataclass, field
19from pathlib import Path
20from typing import Any
22from .config import ProjectConfig
24#: Regex to capture backticked file paths or common file paths in issue text
25_PATH_BACKTICK_RE = re.compile(r"`([a-zA-Z0-9_\-./]+\.[a-zA-Z0-9_\-]+)`")
26_PATH_GENERAL_RE = re.compile(
27 r"(?:^|[\s(\[])([a-zA-Z0-9_\-./]+/(?:[a-zA-Z0-9_\-./]+\.[a-zA-Z0-9_\-]+|[a-zA-Z0-9_\-]+/|\*))"
28)
31@dataclass(frozen=True)
32class IssueScope:
33 """Normalized predicted blast radius and role for a single backlog issue."""
35 issue: int
36 title: str = ""
37 body: str = ""
38 labels: tuple[str, ...] = ()
39 declared_files: tuple[str, ...] = ()
40 predicted_files: tuple[str, ...] = ()
41 role: str = "core"
43 def to_dict(self) -> dict[str, Any]:
44 return {
45 "issue": self.issue,
46 "title": self.title,
47 "role": self.role,
48 "labels": list(self.labels),
49 "declared_files": list(self.declared_files),
50 "predicted_files": list(self.predicted_files),
51 }
54@dataclass(frozen=True)
55class SwarmCluster:
56 """A unit of work within a Swarm Wave consisting of one or more related issues."""
58 cluster_id: str
59 issues: tuple[int, ...]
60 role: str
61 combined_scope: tuple[str, ...]
62 depends_on_issues: tuple[int, ...] = ()
64 def to_dict(self) -> dict[str, Any]:
65 return {
66 "cluster_id": self.cluster_id,
67 "issues": list(self.issues),
68 "role": self.role,
69 "combined_scope": list(self.combined_scope),
70 "depends_on_issues": list(self.depends_on_issues),
71 }
74@dataclass(frozen=True)
75class SwarmWave:
76 """A parallel execution wave containing mutually independent or sequenced clusters."""
78 wave_index: int
79 mode: str # "orthogonal_parallel" or "sequential_dependent"
80 eligible_direct_landing: bool
81 clusters: tuple[SwarmCluster, ...]
83 def to_dict(self) -> dict[str, Any]:
84 return {
85 "wave_index": self.wave_index,
86 "mode": self.mode,
87 "eligible_direct_landing": self.eligible_direct_landing,
88 "clusters": [c.to_dict() for c in self.clusters],
89 }
92@dataclass(frozen=True)
93class SwarmPlan:
94 """Complete deterministic partition and execution plan for a swarm run."""
96 swarm_id: str
97 total_issues: int
98 waves: tuple[SwarmWave, ...]
99 conflict_map: dict[int, tuple[int, ...]] = field(default_factory=dict)
100 issue_scopes: dict[int, IssueScope] = field(default_factory=dict)
102 def to_dict(self) -> dict[str, Any]:
103 return {
104 "swarm_id": self.swarm_id,
105 "total_issues": self.total_issues,
106 "waves": [w.to_dict() for w in self.waves],
107 "conflict_map": {str(k): list(v) for k, v in self.conflict_map.items()},
108 "issue_scopes": {str(k): v.to_dict() for k, v in self.issue_scopes.items()},
109 }
112@dataclass(frozen=True)
113class SwarmWorkerStatus:
114 """Live state for an individual worker operating on a swarm cluster."""
116 cluster_id: str
117 issue: int
118 role: str
119 agent: str = "claude"
120 model: str = "default"
121 step: str = "s0"
122 status: str = "queued" # queued, running, passed, failed, merged
123 updated_at: str = ""
124 details: str = ""
126 def to_dict(self) -> dict[str, Any]:
127 return {
128 "cluster_id": self.cluster_id,
129 "issue": self.issue,
130 "role": self.role,
131 "agent": self.agent,
132 "model": self.model,
133 "step": self.step,
134 "status": self.status,
135 "updated_at": self.updated_at,
136 "details": self.details,
137 }
140@dataclass(frozen=True)
141class SwarmRunState:
142 """State tracking for a live or completed swarm execution."""
144 swarm_id: str
145 total_workers: int
146 active_wave: int = 1
147 workers: tuple[SwarmWorkerStatus, ...] = ()
148 started_at: str = ""
149 completed_at: str | None = None
151 def to_dict(self) -> dict[str, Any]:
152 return {
153 "swarm_id": self.swarm_id,
154 "total_workers": self.total_workers,
155 "active_wave": self.active_wave,
156 "workers": [w.to_dict() for w in self.workers],
157 "started_at": self.started_at,
158 "completed_at": self.completed_at,
159 }
162@dataclass(frozen=True)
163class SwarmRunResult:
164 """Outcome summary for a complete or partial swarm execution."""
166 swarm_id: str
167 status: str # "success", "partial_failure", "failed"
168 total_workers: int
169 passed_count: int
170 failed_count: int
171 dry_run: bool
172 wave_results: tuple[dict[str, Any], ...] = ()
174 def to_dict(self) -> dict[str, Any]:
175 return {
176 "swarm_id": self.swarm_id,
177 "status": self.status,
178 "total_workers": self.total_workers,
179 "passed_count": self.passed_count,
180 "failed_count": self.failed_count,
181 "dry_run": self.dry_run,
182 "wave_results": list(self.wave_results),
183 }
186@dataclass(frozen=True)
187class LandingDecision:
188 """Evaluation of whether a wave can directly land or requires sequential funneling."""
190 mode: str # "direct_batch" or "sequential_funnel"
191 eligible: bool
192 cluster_ids: tuple[str, ...]
193 reason: str = "orthogonal_diffs"
195 def to_dict(self) -> dict[str, Any]:
196 return {
197 "mode": self.mode,
198 "eligible": self.eligible,
199 "cluster_ids": list(self.cluster_ids),
200 "reason": self.reason,
201 }
204@dataclass(frozen=True)
205class SwarmLandingResult:
206 """Outcome report for landing a swarm wave."""
208 swarm_id: str
209 wave_index: int
210 mode: str
211 landed_clusters: tuple[str, ...]
212 healed_clusters: tuple[str, ...]
213 failed_clusters: tuple[str, ...]
214 status: str # "success", "partial_failure", "failed"
216 def to_dict(self) -> dict[str, Any]:
217 return {
218 "swarm_id": self.swarm_id,
219 "wave_index": self.wave_index,
220 "mode": self.mode,
221 "landed_clusters": list(self.landed_clusters),
222 "healed_clusters": list(self.healed_clusters),
223 "failed_clusters": list(self.failed_clusters),
224 "status": self.status,
225 }
228def _normalize_path(p: str) -> str:
229 cleaned = p.strip("`'\" \t\r\n.,;:()")
230 cleaned = cleaned.replace("\\", "/").removeprefix("./").removeprefix("/")
231 return posixpath.normpath(cleaned) if cleaned else ""
234def extract_predicted_paths(text: str) -> list[str]:
235 """Extract candidate file and directory paths from issue title or markdown body."""
236 found: set[str] = set()
237 for match in _PATH_BACKTICK_RE.finditer(text):
238 found.add(_normalize_path(match.group(1)))
239 for match in _PATH_GENERAL_RE.finditer(text):
240 found.add(_normalize_path(match.group(1)))
241 found.discard("")
242 return sorted(found)
245def extract_issue_scope(
246 issue: int,
247 *,
248 title: str = "",
249 body: str = "",
250 labels: list[str] | tuple[str, ...] | None = None,
251 declared_files: list[str] | tuple[str, ...] | None = None,
252 config: ProjectConfig | None = None,
253) -> IssueScope:
254 """Extract and normalize predicted files and roles for an issue."""
255 norm_labels = tuple(sorted(set(labels or ())))
256 norm_declared = tuple(
257 sorted(set(_normalize_path(f) for f in (declared_files or ()) if _normalize_path(f)))
258 )
260 predicted = set(norm_declared)
261 combined_text = f"{title}\n{body}"
262 predicted.update(extract_predicted_paths(combined_text))
264 # Resolve role from labels or config
265 resolved_role = "core"
266 for label in norm_labels:
267 if label.startswith("role:"):
268 resolved_role = label.removeprefix("role:")
269 break
270 if label.startswith("area:"):
271 resolved_role = label.removeprefix("area:")
272 break
274 if config and config.knobs.implementer_agents:
275 if resolved_role not in config.knobs.implementer_agents:
276 resolved_role = "core" if "core" in config.knobs.implementer_agents else resolved_role
278 # Default directory hints based on role or labels if no specific files found
279 if not predicted:
280 if "visual" in resolved_role or any("visual" in lbl for lbl in norm_labels):
281 predicted.add("keel-visual/*")
282 elif "docs" in resolved_role or any("docs" in lbl for lbl in norm_labels):
283 predicted.add("docs/*")
284 elif "website" in resolved_role or any("website" in lbl for lbl in norm_labels):
285 predicted.add("website/*")
286 elif "cli" in resolved_role or any("cli" in lbl for lbl in norm_labels):
287 predicted.add("src/keel/cli.py")
288 else:
289 predicted.add(f"scope/issue-{issue}/*")
291 return IssueScope(
292 issue=issue,
293 title=title.strip(),
294 body=body.strip(),
295 labels=norm_labels,
296 declared_files=norm_declared,
297 predicted_files=tuple(sorted(predicted)),
298 role=resolved_role,
299 )
302def paths_intersect(path_a: str, path_b: str) -> bool:
303 """True if path_a and path_b refer to the same file or overlapping glob/directory."""
304 a = _normalize_path(path_a)
305 b = _normalize_path(path_b)
306 if not a or not b:
307 return False
308 if a == b:
309 return True
310 if a == "*" or b == "*":
311 return True
312 # Directory prefix overlap
313 a_dir = a if a.endswith("/") else a + "/"
314 b_dir = b if b.endswith("/") else b + "/"
315 if b.startswith(a_dir) or a.startswith(b_dir):
316 return True
317 # Glob matching
318 if "*" in a and fnmatch.fnmatch(b, a):
319 return True
320 if "*" in b and fnmatch.fnmatch(a, b):
321 return True
322 return False
325def scopes_intersect(scope_a: IssueScope, scope_b: IssueScope) -> tuple[str, ...]:
326 """Return common overlapping paths between two issue scopes."""
327 overlaps: set[str] = set()
328 for fa in scope_a.predicted_files:
329 for fb in scope_b.predicted_files:
330 if paths_intersect(fa, fb):
331 overlaps.add(fa if len(fa) <= len(fb) else fb)
332 return tuple(sorted(overlaps))
335def build_swarm_plan(
336 issue_scopes: list[IssueScope] | tuple[IssueScope, ...],
337 *,
338 swarm_id: str | None = None,
339 config: ProjectConfig | None = None,
340) -> SwarmPlan:
341 """Deterministically partition candidate issues into orthogonal Waves and Clusters."""
342 if not issue_scopes:
343 now_id = swarm_id or (
344 "swarm-" + datetime.datetime.now(datetime.UTC).strftime("%Y%m%d-%H%M%S")
345 )
346 return SwarmPlan(
347 swarm_id=now_id,
348 total_issues=0,
349 waves=(),
350 conflict_map={},
351 issue_scopes={},
352 )
354 # Sort issues for determinism
355 sorted_scopes = sorted(issue_scopes, key=lambda s: s.issue)
356 scope_by_id = {s.issue: s for s in sorted_scopes}
358 # Compute pairwise conflict graph
359 conflict_map: dict[int, list[int]] = {s.issue: [] for s in sorted_scopes}
360 for i, sa in enumerate(sorted_scopes):
361 for sb in sorted_scopes[i + 1 :]:
362 if scopes_intersect(sa, sb):
363 conflict_map[sa.issue].append(sb.issue)
364 conflict_map[sb.issue].append(sa.issue)
366 frozen_conflicts: dict[int, tuple[int, ...]] = {
367 k: tuple(sorted(v)) for k, v in conflict_map.items()
368 }
370 # Greedy wave partitioning (independent set partitioning)
371 remaining_issues = [s.issue for s in sorted_scopes]
372 waves: list[SwarmWave] = []
373 wave_idx = 1
375 assigned_prior_issues: set[int] = set()
377 while remaining_issues:
378 current_wave_issues: list[int] = []
379 current_wave_scope_set: set[str] = set()
381 for issue_num in list(remaining_issues):
382 scope = scope_by_id[issue_num]
383 # Check if this issue conflicts with anything already placed in current wave
384 has_wave_conflict = False
385 for placed_num in current_wave_issues:
386 if issue_num in frozen_conflicts.get(placed_num, ()):
387 has_wave_conflict = True
388 break
390 if not has_wave_conflict:
391 current_wave_issues.append(issue_num)
392 current_wave_scope_set.update(scope.predicted_files)
393 remaining_issues.remove(issue_num)
395 # Build clusters for this wave
396 clusters: list[SwarmCluster] = []
397 for issue_num in current_wave_issues:
398 sc = scope_by_id[issue_num]
399 # Find dependencies on previously assigned waves
400 deps = tuple(
401 sorted(
402 dep
403 for dep in frozen_conflicts.get(issue_num, ())
404 if dep in assigned_prior_issues
405 )
406 )
407 clusters.append(
408 SwarmCluster(
409 cluster_id=f"cluster-{wave_idx}-{issue_num}",
410 issues=(issue_num,),
411 role=sc.role,
412 combined_scope=sc.predicted_files,
413 depends_on_issues=deps,
414 )
415 )
417 assigned_prior_issues.update(current_wave_issues)
419 # Determine mode & direct landing eligibility
420 is_orthogonal = len(current_wave_issues) > 0
421 waves.append(
422 SwarmWave(
423 wave_index=wave_idx,
424 mode="orthogonal_parallel" if is_orthogonal else "sequential_dependent",
425 eligible_direct_landing=is_orthogonal,
426 clusters=tuple(clusters),
427 )
428 )
429 wave_idx += 1
431 now_id = swarm_id or (
432 "swarm-" + datetime.datetime.now(datetime.UTC).strftime("%Y%m%d-%H%M%S")
433 )
434 return SwarmPlan(
435 swarm_id=now_id,
436 total_issues=len(sorted_scopes),
437 waves=tuple(waves),
438 conflict_map=frozen_conflicts,
439 issue_scopes=scope_by_id,
440 )
443def render_swarm_plan_text(plan: SwarmPlan) -> str:
444 """Render human-readable tabular text summary of the SwarmPlan."""
445 lines: list[str] = [
446 f"keel swarm plan — {plan.swarm_id}",
447 f" total issues : {plan.total_issues}",
448 f" total waves : {len(plan.waves)}",
449 "",
450 ]
451 for w in plan.waves:
452 landing = (
453 "eligible for direct batch landing"
454 if w.eligible_direct_landing
455 else "sequential merge funnel"
456 )
457 lines.append(f"Wave {w.wave_index} [{w.mode}] — {landing}:")
458 for c in w.clusters:
459 issues_str = ", ".join(f"#{i}" for i in c.issues)
460 scope_prefix = c.combined_scope[:3]
461 scope_str = ", ".join(scope_prefix) + (
462 "..." if len(c.combined_scope) > 3 else ""
463 )
464 dep_str = (
465 f" (depends on: {', '.join(f'#{d}' for d in c.depends_on_issues)})"
466 if c.depends_on_issues
467 else ""
468 )
469 lines.append(
470 f" • Cluster {c.cluster_id}: {issues_str} [{c.role}] → {scope_str}{dep_str}"
471 )
472 lines.append("")
473 return "\n".join(lines).strip()
476def render_swarm_plan_tree(plan: SwarmPlan) -> str:
477 """Render a visual ASCII/Unicode DAG dependency tree of the SwarmPlan."""
478 if plan.total_issues == 0:
479 return f"keel swarm plan — {plan.swarm_id} (0 issues)"
481 direct_count = sum(1 for w in plan.waves if w.eligible_direct_landing)
482 hdr_a = f"│ 🐝 Keel Swarm Plan — {plan.swarm_id:<38} │"
483 hdr_b = (
484 f"│ Issues: {plan.total_issues:<3} │ Waves: {len(plan.waves):<3} "
485 f"│ Direct Landing Waves: {direct_count:<2} │"
486 )
487 lines: list[str] = [
488 "╭" + "─" * 62 + "╮",
489 hdr_a,
490 hdr_b,
491 "╰" + "─" * 62 + "╯",
492 "",
493 ]
495 for w in plan.waves:
496 mode_icon = "⚡" if w.eligible_direct_landing else "⏳"
497 landing_label = (
498 "Direct Batch Landing" if w.eligible_direct_landing else "Sequential Funnel"
499 )
500 lines.append(f"{mode_icon} Wave {w.wave_index} [{w.mode}] — {landing_label}")
502 num_clusters = len(w.clusters)
503 for i, c in enumerate(w.clusters):
504 is_last_cluster = i == num_clusters - 1
505 c_prefix = "└── " if is_last_cluster else "├── "
506 c_indent = " " if is_last_cluster else "│ "
508 issues_str = ", ".join(f"#{num}" for num in c.issues)
509 lines.append(f"{c_prefix}📦 Cluster {c.cluster_id} ({issues_str}) [{c.role}]")
511 scope_items = list(c.combined_scope)
512 has_deps = bool(c.depends_on_issues)
514 scope_branch = "├── " if has_deps else "└── "
515 lines.append(
516 f"{c_indent}{scope_branch}Scope: {', '.join(scope_items[:3])}"
517 f"{'...' if len(scope_items) > 3 else ''}"
518 )
520 if has_deps:
521 dep_issues = ", ".join(f"#{d}" for d in c.depends_on_issues)
522 lines.append(f"{c_indent}└── ⛓️ Depends on: {dep_issues}")
523 lines.append("")
525 return "\n".join(lines).rstrip()
528def render_swarm_status_dashboard(state: SwarmRunState | None) -> str:
529 """Render a live terminal ASCII matrix status board of the swarm run."""
530 if state is None:
531 return "keel swarm status — no active or recent swarm run found."
533 status_badges = {
534 "queued": "[QUEUED ⏳]",
535 "running": "[RUNNING ⚙️]",
536 "passed": "[PASSED ✓]",
537 "failed": "[FAILED ✗]",
538 "merged": "[MERGED 🚢]",
539 }
541 start_str = state.started_at[:19] if state.started_at else "pending"
542 hdr_info = (
543 f"│ Active Wave: {state.active_wave:<3} │ Total Workers: {state.total_workers:<3} "
544 f"│ Started: {start_str:<24} │"
545 )
546 cols_hdr = (
547 f"│ {'Cluster':<16} │ {'Issue':<6} │ {'Role':<8} │ {'Step':<5} "
548 f"│ {'Agent / Model':<16} │ {'Status':<10} │"
549 )
550 lines = [
551 "╭" + "─" * 74 + "╮",
552 f"│ 🐝 Keel Swarm Live Status — {state.swarm_id:<44} │",
553 hdr_info,
554 "├" + "─" * 74 + "┤",
555 cols_hdr,
556 "├" + "─" * 74 + "┤",
557 ]
559 for w in state.workers:
560 badge = status_badges.get(w.status, f"[{w.status.upper()}]")
561 agent_str = f"{w.agent}:{w.model}"[:16]
562 row_str = (
563 f"│ {w.cluster_id:<16} │ #{w.issue:<5} │ {w.role:<8} │ {w.step:<5} "
564 f"│ {agent_str:<16} │ {badge:<10} │"
565 )
566 lines.append(row_str)
568 lines.append("╰" + "─" * 74 + "╯")
569 return "\n".join(lines)
572def resolve_swarm_state_dir(root: str | Path = ".") -> Path:
573 """Ensure and return the path to `.keel/state/swarm/` directory."""
574 p = Path(root) / ".keel" / "state" / "swarm"
575 p.mkdir(parents=True, exist_ok=True)
576 return p
579def save_swarm_state(state: SwarmRunState, root: str | Path = ".") -> Path:
580 """Persist a SwarmRunState JSON snapshot to `.keel/state/swarm/<swarm_id>.json`."""
581 state_dir = resolve_swarm_state_dir(root)
582 file_path = state_dir / f"{state.swarm_id}.json"
583 file_path.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8")
584 return file_path
587def load_swarm_state(swarm_id: str, root: str | Path = ".") -> SwarmRunState | None:
588 """Load a SwarmRunState from disk if present."""
589 state_dir = Path(root) / ".keel" / "state" / "swarm"
590 file_path = state_dir / f"{swarm_id}.json"
591 if not file_path.exists():
592 return None
593 try:
594 data = json.loads(file_path.read_text(encoding="utf-8"))
595 workers = tuple(
596 SwarmWorkerStatus(
597 cluster_id=str(w.get("cluster_id", "")),
598 issue=int(w.get("issue", 0)),
599 role=str(w.get("role", "core")),
600 agent=str(w.get("agent", "claude")),
601 model=str(w.get("model", "default")),
602 step=str(w.get("step", "s0")),
603 status=str(w.get("status", "queued")),
604 updated_at=str(w.get("updated_at", "")),
605 details=str(w.get("details", "")),
606 )
607 for w in data.get("workers", [])
608 )
609 return SwarmRunState(
610 swarm_id=str(data.get("swarm_id", swarm_id)),
611 total_workers=int(data.get("total_workers", len(workers))),
612 active_wave=int(data.get("active_wave", 1)),
613 workers=workers,
614 started_at=str(data.get("started_at", "")),
615 completed_at=data.get("completed_at"),
616 )
617 except (json.JSONDecodeError, ValueError, KeyError):
618 return None
621def update_worker_state(
622 state: SwarmRunState,
623 cluster_id: str,
624 *,
625 step: str | None = None,
626 status: str | None = None,
627 details: str | None = None,
628) -> SwarmRunState:
629 """Return a new SwarmRunState with the specified worker's fields updated."""
630 updated_workers = []
631 for w in state.workers:
632 if w.cluster_id == cluster_id:
633 updated_workers.append(
634 SwarmWorkerStatus(
635 cluster_id=w.cluster_id,
636 issue=w.issue,
637 role=w.role,
638 agent=w.agent,
639 model=w.model,
640 step=step if step is not None else w.step,
641 status=status if status is not None else w.status,
642 updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
643 details=details if details is not None else w.details,
644 )
645 )
646 else:
647 updated_workers.append(w)
649 return SwarmRunState(
650 swarm_id=state.swarm_id,
651 total_workers=state.total_workers,
652 active_wave=state.active_wave,
653 workers=tuple(updated_workers),
654 started_at=state.started_at,
655 completed_at=state.completed_at,
656 )
659def rebalance_swarm_plan(plan: SwarmPlan, failed_issue: int) -> SwarmPlan:
660 """Dynamically recalculate a SwarmPlan when an issue fails during execution.
662 Any subsequent wave clusters that depended on ``failed_issue`` will have
663 the failed dependency omitted, while independent disjoint clusters
664 proceed without interruption.
665 """
666 new_waves = []
667 for w in plan.waves:
668 new_clusters = []
669 for c in w.clusters:
670 if failed_issue in c.issues:
671 continue
672 new_clusters.append(c)
673 if new_clusters:
674 new_waves.append(
675 SwarmWave(
676 wave_index=w.wave_index,
677 mode=w.mode,
678 eligible_direct_landing=w.eligible_direct_landing,
679 clusters=tuple(new_clusters),
680 )
681 )
683 return SwarmPlan(
684 swarm_id=plan.swarm_id,
685 total_issues=sum(len(c.issues) for w in new_waves for c in w.clusters),
686 waves=tuple(new_waves),
687 conflict_map=plan.conflict_map,
688 issue_scopes=plan.issue_scopes,
689 )
692def render_swarm_run_result(result: SwarmRunResult) -> str:
693 """Render a human-readable text summary of a SwarmRunResult."""
694 status_icon = (
695 "✓"
696 if result.status == "success"
697 else ("⚠️" if result.status == "partial_failure" else "✗")
698 )
699 lines = [
700 f"keel swarm run — {result.swarm_id}",
701 f" status : {result.status} {status_icon}",
702 f" total workers : {result.total_workers}",
703 f" passed : {result.passed_count}",
704 f" failed : {result.failed_count}",
705 f" dry-run : {'true' if result.dry_run else 'false'}",
706 f" total waves : {len(result.wave_results)}",
707 ]
708 return "\n".join(lines)
711def evaluate_wave_landing_mode(
712 wave: SwarmWave,
713 pr_diff_map: dict[str, list[str] | tuple[str, ...]],
714) -> LandingDecision:
715 """Evaluate whether a wave can directly land or requires sequential funneling."""
716 cluster_ids = tuple(c.cluster_id for c in wave.clusters)
717 if len(cluster_ids) <= 1:
718 return LandingDecision(
719 mode="direct_batch",
720 eligible=True,
721 cluster_ids=cluster_ids,
722 reason="single_cluster",
723 )
725 # Check pairwise disjointness of actual diff files
726 has_conflict = False
727 for i, c1 in enumerate(wave.clusters):
728 diff1 = pr_diff_map.get(c1.cluster_id, c1.combined_scope)
729 for c2 in wave.clusters[i + 1 :]:
730 diff2 = pr_diff_map.get(c2.cluster_id, c2.combined_scope)
731 for f1 in diff1:
732 for f2 in diff2:
733 if paths_intersect(f1, f2):
734 has_conflict = True
735 break
736 if has_conflict:
737 break
738 if has_conflict:
739 break
740 if has_conflict:
741 break
743 if not has_conflict:
744 return LandingDecision(
745 mode="direct_batch",
746 eligible=True,
747 cluster_ids=cluster_ids,
748 reason="orthogonal_diff_trees",
749 )
751 return LandingDecision(
752 mode="sequential_funnel",
753 eligible=False,
754 cluster_ids=cluster_ids,
755 reason="overlapping_diff_trees",
756 )
759def render_swarm_landing_result(result: SwarmLandingResult) -> str:
760 """Render human-readable summary of a SwarmLandingResult."""
761 status_icon = (
762 "✓"
763 if result.status == "success"
764 else ("⚠️" if result.status == "partial_failure" else "✗")
765 )
766 lines = [
767 f"keel swarm land — {result.swarm_id} (wave {result.wave_index})",
768 f" status : {result.status} {status_icon}",
769 f" mode : {result.mode}",
770 f" landed : {', '.join(result.landed_clusters) if result.landed_clusters else 'none'}",
771 f" healed : {', '.join(result.healed_clusters) if result.healed_clusters else 'none'}",
772 f" failed : {', '.join(result.failed_clusters) if result.failed_clusters else 'none'}",
773 ]
774 return "\n".join(lines)