From 4249b91092697c909831d950d8b592ba04464453 Mon Sep 17 00:00:00 2001 From: Damian Sova Date: Fri, 14 Aug 2026 16:12:05 +0200 Subject: [PATCH 1/4] feat(supervisor): human involvement gate with evaluate_all refactoring Add _check_human_involvement_gate() that blocks autonomous scheduling for issues labeled agent:human-only or agent:interactive-recommended. The gate extracts labels from the dependency graph when the caller does not provide them (fail-closed). is_human_only() and is_interactive_recommended() added to dependency_graph.py. Extract five helpers from evaluate_all to reduce cognitive complexity from 33 to ~11: _fetch_mergeability_map, _fetch_file_overlap_sets, _resolve_task_ids, _effective_gate, _update_remaining_capacity. Closes #605 --- .claude/rules/architecture.md | 2 +- sova/supervisor/dependency_graph.py | 14 ++ sova/supervisor/progression.py | 212 +++++++++++++++--------- tests/test_dependency_graph.py | 82 ++++++++++ tests/test_progression.py | 244 ++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+), 81 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index e1eaa230..552062ec 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -63,7 +63,7 @@ SOVA has four main components: - **`sova/commands/`** -- command and skill distribution: catalog (discover + classify + skills dir), templates (regex rendering), manifest (SHA-256 tracking), distribution (install/update/diff with conflict detection for commands, guidelines, and skills) - **`sova/config/`** -- Pydantic Settings v2, TOML loader, project registry, per-request project context (`context.py`, moved from dashboard/project_context.py) - **`sova/db/`** -- SQLAlchemy 2.0 async ORM models (TaskRun, OutputLine, StepExecution, FailureRecord, CostRecord, Memory, TaskAssessmentRecord, IssueLifecycle, LifecyclePhaseRecord, WorkflowDefinition, CommandContract, PREvent, MergeQueueEntry), session factory (SQLite default, PostgreSQL optional). See `docs/database-guidelines.md` for session patterns, JSON column gotchas, and migration conventions. -- **`sova/supervisor/`** -- Supervisor-level services. `progression.py`: `TaskProgressionEngine` evaluates all active tasks on each poll cycle and produces `ProgressionDecision` objects. Deterministic state machine with 13 gate checks (GitHub API rate limit, memory pressure, dependency graph, CodeRabbit quota, CI budget, agent slot capacity, per-issue budget, duplicate agent detection, repeated researcher failure, ownership, merge conflict, file overlap, address-review circuit breaker). GitHub API rate limit gate runs first (global, precomputed once per cycle via identity-keyed `github_quota.py` trackers); fails open if tracker unavailable. Rate limit check runs before `build_dependency_graph()` to avoid wasting API calls during cooldown. Memory pressure gate runs second (global system condition, precomputed once per cycle); fails open on psutil errors; warn/block thresholds configured via `[memory_guard]` in `sova.toml` (`enabled`, `warn_threshold_gb`, `block_threshold_gb`). Repeated failure gate (`_check_repeated_failures_gate`) blocks SPAWN_RESEARCHER when the count of failed researcher runs since the last successful run >= `supervisor.max_researcher_failures` (default 3, 0=unlimited); prevents infinite re-spawn loops on closed or mis-labelled issues. Config: `SupervisorConfig` in `sova.toml` under `[supervisor]` (enable flag, per-phase auto flags, `require_approval`, `respect_dependencies`, `poll_interval_seconds`, `max_researcher_failures`, `max_spawns_per_cycle`, `ci_warn_minutes`, `ci_block_minutes`). `require_approval=True` (default) stores actionable progression decisions in an in-memory plan instead of executing them; the supervisor dashboard shows a "Pending Actions" panel with per-item approve/skip controls. `auto_research` defaults to `False` (previously `True`) so new installs do not spawn researchers on all triaged issues on first start. `auto_address_review` enables supervisor-level address-review spawning when a SOVA review verdict is "revise" or "block" (complements the handoff-based intra-pipeline chaining with a supervisor fallback for command-path reviews). `github_quota.py`: identity-keyed in-memory rate limit state trackers for GitHub API; records hits from the shell layer per `github_user`, provides cooldown gate for the progression engine, PR monitor, and `api_health` status for the dashboard; emits feed events on hit/recovery transitions. `get_github_quota_tracker(identity)` returns a per-identity tracker; all callers pass `github_user` from config or adapter. `track_rate_limit(result, identity)` is the shared function called by `GitHubAdapter._track_rate_limit()`, `sova/git/pr.py:_track_gh_rate_limit()`, and `sova/supervisor/pr_monitor.py:_track_gh_rate_limit_pr_monitor()` to feed the tracker from all `gh` CLI call sites. `pr_monitor.py`: `PRMonitor._poll_cycle()` checks `should_skip()` both before API calls and after `asyncio.gather()` to catch rate limits triggered mid-cycle by `_is_coderabbit_rate_limited`. `coderabbit_quota.py`: quota tracking for CodeRabbit review rate limits. `ci_budget.py`: GitHub Actions CI minutes tracking via billing API with TTL caching; identity-keyed trackers matching `github_quota.py` pattern; fail-open on API errors (zero-state budget never blocks). `dependency_graph.py`: builds a `DependencyGraph` from issue bodies (parses `## Dependencies` sections). Issues labeled `type: epic` are tracking containers and are excluded from `get_ready_tasks()` (treated similarly to `HUMAN_ONLY` state). Epic dependencies are skipped by both `_are_dependencies_satisfied()` and `_check_dependency_gate()`: when a child issue lists an epic in its Dependencies section, the epic does not block the child (epics are tracking containers, not real blockers). Without this, a deadlock occurs: children blocked by the non-DONE epic, but the epic can only auto-close when all children are DONE. Epics are auto-closed by `TaskProgressionEngine.auto_close_epics()` when all child issues (those listing the epic in their Dependencies section) reach `DONE` state. `_STATE_ACTIONS` maps each `TaskState` to the actions surfaced in the graph drawer (e.g., BACKLOG exposes Triage, TRIAGED/NEEDS_SPEC expose Run Researcher, RESEARCHED exposes Run Developer, IN_REVIEW exposes Integrate PR and Address PR). `planner.py`: `SupervisorPlanner` assembles a resource snapshot (GitHub quota, CodeRabbit quota, CI budget, agent slots, open PRs, issue counts, recent failures) and supervisor persona (#600), calls the Anthropic API (Sonnet via direct httpx, matching `llm_suggestion_service.py` pattern) to produce a structured `PlanResult` with reasoning, approved actions, and deferred items. `evaluate_all(plan=...)` filters actionable decisions against the plan (can only subtract, never add/unblock). Gated behind `supervisor.llm_planning` config (default false); returns None on missing API key, timeout, or error (falls back to deterministic mode). Persona braces are escaped before `str.format()` to prevent `KeyError` on user-authored `{text}` in persona files. +- **`sova/supervisor/`** -- Supervisor-level services. `progression.py`: `TaskProgressionEngine` evaluates all active tasks on each poll cycle and produces `ProgressionDecision` objects. Deterministic state machine with 13 gate checks (GitHub API rate limit, memory pressure, dependency graph, CodeRabbit quota, CI budget, agent slot capacity, per-issue budget, duplicate agent detection, repeated researcher failure, ownership, merge conflict, file overlap, address-review circuit breaker). GitHub API rate limit gate runs first (global, precomputed once per cycle via identity-keyed `github_quota.py` trackers); fails open if tracker unavailable. Rate limit check runs before `build_dependency_graph()` to avoid wasting API calls during cooldown. Memory pressure gate runs second (global system condition, precomputed once per cycle); fails open on psutil errors; warn/block thresholds configured via `[memory_guard]` in `sova.toml` (`enabled`, `warn_threshold_gb`, `block_threshold_gb`). Repeated failure gate (`_check_repeated_failures_gate`) blocks SPAWN_RESEARCHER when the count of failed researcher runs since the last successful run >= `supervisor.max_researcher_failures` (default 3, 0=unlimited); prevents infinite re-spawn loops on closed or mis-labelled issues. Config: `SupervisorConfig` in `sova.toml` under `[supervisor]` (enable flag, per-phase auto flags, `require_approval`, `respect_dependencies`, `poll_interval_seconds`, `max_researcher_failures`, `max_spawns_per_cycle`, `ci_warn_minutes`, `ci_block_minutes`). `require_approval=True` (default) stores actionable progression decisions in an in-memory plan instead of executing them; the supervisor dashboard shows a "Pending Actions" panel with per-item approve/skip controls. `auto_research` defaults to `False` (previously `True`) so new installs do not spawn researchers on all triaged issues on first start. `auto_address_review` enables supervisor-level address-review spawning when a SOVA review verdict is "revise" or "block" (complements the handoff-based intra-pipeline chaining with a supervisor fallback for command-path reviews). `github_quota.py`: identity-keyed in-memory rate limit state trackers for GitHub API; records hits from the shell layer per `github_user`, provides cooldown gate for the progression engine, PR monitor, and `api_health` status for the dashboard; emits feed events on hit/recovery transitions. `get_github_quota_tracker(identity)` returns a per-identity tracker; all callers pass `github_user` from config or adapter. `track_rate_limit(result, identity)` is the shared function called by `GitHubAdapter._track_rate_limit()`, `sova/git/pr.py:_track_gh_rate_limit()`, and `sova/supervisor/pr_monitor.py:_track_gh_rate_limit_pr_monitor()` to feed the tracker from all `gh` CLI call sites. `pr_monitor.py`: `PRMonitor._poll_cycle()` checks `should_skip()` both before API calls and after `asyncio.gather()` to catch rate limits triggered mid-cycle by `_is_coderabbit_rate_limited`. `coderabbit_quota.py`: quota tracking for CodeRabbit review rate limits. `ci_budget.py`: GitHub Actions CI minutes tracking via billing API with TTL caching; identity-keyed trackers matching `github_quota.py` pattern; fail-open on API errors (zero-state budget never blocks). `dependency_graph.py`: builds a `DependencyGraph` from issue bodies (parses `## Dependencies` sections). Issues labeled `type: epic` are tracking containers. They are: (1) excluded from `get_ready_tasks()`, (2) skipped as dependencies by `_are_dependencies_satisfied()` and `_check_dependency_gate()` (prevents deadlock: children would block on the non-DONE epic, but the epic can only close when all children are DONE), (3) auto-closed by `auto_close_epics()` when all child issues reach DONE state. `_STATE_ACTIONS` maps each `TaskState` to the actions surfaced in the graph drawer (e.g., BACKLOG exposes Triage, TRIAGED/NEEDS_SPEC expose Run Researcher, RESEARCHED exposes Run Developer, IN_REVIEW exposes Integrate PR and Address PR). `planner.py`: `SupervisorPlanner` assembles a resource snapshot (GitHub quota, CodeRabbit quota, CI budget, agent slots, open PRs, issue counts, recent failures) and supervisor persona (#600), calls the Anthropic API (Sonnet via direct httpx, matching `llm_suggestion_service.py` pattern) to produce a structured `PlanResult` with reasoning, approved actions, and deferred items. `evaluate_all(plan=...)` filters actionable decisions against the plan (can only subtract, never add/unblock). Gated behind `supervisor.llm_planning` config (default false); returns None on missing API key, timeout, or error (falls back to deterministic mode). Persona braces are escaped before `str.format()` to prevent `KeyError` on user-authored `{text}` in persona files. - **Supervisor API caching layer**: four TTL caches reduce GitHub API consumption from ~4,800 calls/hr to ~1,000: (1) `coderabbit_quota.py:_sync_cache` (300s TTL, per-repo single-flight lock, `None` return on API failure prevents caching errors); (2) `pr_monitor.py:_rate_check_cache` (300s TTL, keyed by `(repo, pr_number)` tuple, halved TTL for rate-limited results); (3) `pr_service.py:_pr_cache` (60s TTL, shared across supervisor/monitor/dashboard); (4) `dependency_graph.py:_graph_cache` (120s TTL, per-repo, invalidated on agent spawn and epic close, milestone-filtered builds bypass). The daemon enforces a 60s minimum poll interval (`_MIN_POLL_INTERVAL`) via runtime clamping. Cache key resolution uses `adapter.repo` (GitHub) or `adapter.project_key` (Jira) for cross-adapter compatibility. - **`.claude/benchmark/`** -- Development velocity benchmark infrastructure. `resolve_root.sh`: shared bash function mirroring `get_primary_worktree_root()` that resolves the main checkout path from any worktree via `git rev-parse --git-common-dir`, sourced by all other scripts so JSONL logs always write to the primary checkout (surviving worktree cleanup). `log.sh`: JSONL event logger for issue-level benchmarks (session_start, develop_complete, pr_created, etc.). `session_start_hook.sh`: Claude Code SessionStart hook that creates `issue-{N}.jsonl` and logs `session_start` when opening a session on a benchmark branch (`feat/issue-NNN`). `session_end_hook.sh`: Claude Code Stop hook that captures session metrics (model, duration, token counts, cost) from transcript files and appends to benchmark log; self-healing (creates the log file if `session_start` didn't run). Both hooks registered in `.claude/settings.json` (SessionStart + Stop); `_copy_claude_artifacts` copies `settings.json` to SOVA-created worktrees so hooks fire there. For full coverage in Claude Code-created worktrees (EnterWorktree), add the same hooks to `~/.claude/settings.json`. Logs stored at `.claude/benchmark/issue-{N}.jsonl`, gitignored. Used for SOVA vs interactive development velocity comparison (issue #6). diff --git a/sova/supervisor/dependency_graph.py b/sova/supervisor/dependency_graph.py index ecb2a36e..7423807d 100644 --- a/sova/supervisor/dependency_graph.py +++ b/sova/supervisor/dependency_graph.py @@ -49,6 +49,16 @@ def is_epic(labels: list[str]) -> bool: return any(label.lower().strip() == "type: epic" for label in labels) +def is_human_only(labels: list[str]) -> bool: + """Return True if labels include 'agent:human-only' (case-insensitive).""" + return any(label.lower().strip() == "agent:human-only" for label in labels) + + +def is_interactive_recommended(labels: list[str]) -> bool: + """Return True if labels include 'agent:interactive-recommended' (case-insensitive).""" + return any(label.lower().strip() == "agent:interactive-recommended" for label in labels) + + # States that should be excluded from "ready to work on" -- either already # being worked on, already completed, or explicitly rejected/blocked. _EXCLUDED_FROM_READY: frozenset[TaskState] = frozenset( @@ -382,6 +392,8 @@ def to_dict( }, ] + task_is_human_only = is_human_only(task.labels) + task_is_interactive = is_interactive_recommended(task.labels) node: dict = { "id": tid, "title": task.title, @@ -392,6 +404,8 @@ def to_dict( "available_actions": actions, "priority": _extract_priority(task.labels), "is_epic": is_epic(task.labels), + "is_human_only": task_is_human_only, + "is_interactive_recommended": task_is_interactive and not task_is_human_only, } if pr_info: node["pr_number"] = pr_info.get("pr_number") diff --git a/sova/supervisor/progression.py b/sova/supervisor/progression.py index f59cc332..ba8dfa54 100644 --- a/sova/supervisor/progression.py +++ b/sova/supervisor/progression.py @@ -24,7 +24,14 @@ from sova.dashboard.services.agent_validation import _check_issue_budget from sova.db.models import TaskRun from sova.git.pr import PRInfo, find_pr_for_issue -from sova.supervisor.dependency_graph import DependencyGraph, build_dependency_graph, invalidate_graph_cache, is_epic +from sova.supervisor.dependency_graph import ( + DependencyGraph, + build_dependency_graph, + invalidate_graph_cache, + is_epic, + is_human_only, + is_interactive_recommended, +) from sova.supervisor.file_overlap import ( BranchFileSet, check_file_overlap, @@ -124,6 +131,75 @@ def __init__( self._last_graph: DependencyGraph | None = None self._repo_cache_key: str = getattr(adapter, "repo", "") or getattr(adapter, "project_key", "") or "" + async def _fetch_mergeability_map(self) -> dict: + """Fetch merge conflict state for all open PRs (fail-open).""" + try: + from sova.dashboard.services.pr_service import get_pr_mergeability_map + + return await get_pr_mergeability_map() + except Exception: + log.debug("evaluate_all.mergeability_fetch_failed", exc_info=True) + return {} + + async def _fetch_file_overlap_sets(self) -> list[BranchFileSet] | None: + """Fetch active branch file sets for the file overlap gate (fail-open).""" + if not self._config.file_overlap_gate: + return None + try: + return await get_active_branch_file_sets( + self._session_factory, + self._project_dir, + ) + except Exception: + log.debug("evaluate_all.file_overlap_fetch_failed", exc_info=True) + return None + + def _resolve_task_ids(self, graph: DependencyGraph) -> list[int]: + """Resolve which task IDs to evaluate, respecting task_queue order.""" + task_queue = self._config.task_queue + if not task_queue: + return list(graph.nodes) + + node_set = set(graph.nodes) + task_ids: list[int] = [] + skipped: list[int] = [] + for qid in task_queue: + (task_ids if qid in node_set else skipped).append(qid) + if skipped: + log.warning("evaluate_all.queue_items_not_in_graph", skipped=skipped) + return task_ids + + @staticmethod + def _effective_gate( + global_blocker: BlockReason | None, + exhausted: bool, + gate: str, + detail_prefix: str, + ) -> BlockReason | None: + """Return global blocker, or a batch-exhaustion blocker if capacity is spent.""" + if global_blocker is not None: + return global_blocker + if exhausted: + return BlockReason(gate=gate, detail=f"{detail_prefix} ({gate} capacity exhausted)") + return None + + @staticmethod + def _update_remaining_capacity( + decision: ProgressionDecision, + remaining_slots: int, + remaining_quota: bool, + ) -> tuple[int, bool]: + """Decrement capacity counters for actionable decisions.""" + if decision.action in NON_ACTIONABLE_ACTIONS or decision.action in ( + ProgressionAction.SPAWN_REBASE, + ProgressionAction.RESET_STALE_STATE, + ): + return remaining_slots, remaining_quota + remaining_slots -= 1 + if decision.action == ProgressionAction.SPAWN_DEVELOPER: + remaining_quota = False + return remaining_slots, remaining_quota + async def evaluate_all(self, *, plan: PlanResult | None = None) -> list[ProgressionDecision]: """Scan all active tasks, return next action for each. @@ -131,7 +207,6 @@ async def evaluate_all(self, *, plan: PlanResult | None = None) -> list[Progress pair is not in the plan's approved list are converted to WAIT. Deterministic gates still hard-block regardless of the plan. """ - # Check rate limit before making any API calls (graph build calls list_tasks) global_rate_limit = self._check_github_rate_limit_gate() if global_rate_limit is not None: log.info("evaluate_all.skipped_rate_limited") @@ -144,36 +219,14 @@ async def evaluate_all(self, *, plan: PlanResult | None = None) -> list[Progress return [] self._last_graph = graph - - # Load config once for the whole evaluation cycle cfg = load_config(self._project_dir) - # Pre-compute global gates once, then decrement as slots/quota are consumed global_memory = self._check_memory_pressure_gate(cfg) global_quota = await self._check_quota_gate(ProgressionAction.SPAWN_DEVELOPER, cfg=cfg) global_ci_budget = await self._check_ci_budget_gate(cfg=cfg) + precomputed_conflicts = await self._fetch_mergeability_map() + precomputed_file_sets = await self._fetch_file_overlap_sets() - # Pre-fetch merge conflict state for all open PRs (fail-open) - try: - from sova.dashboard.services.pr_service import get_pr_mergeability_map - - precomputed_conflicts = await get_pr_mergeability_map() - except Exception: - log.debug("evaluate_all.mergeability_fetch_failed", exc_info=True) - precomputed_conflicts = {} - - # Pre-fetch active branch file sets for file overlap gate (fail-open) - precomputed_file_sets: list[BranchFileSet] | None = None - if self._config.file_overlap_gate: - try: - precomputed_file_sets = await get_active_branch_file_sets( - self._session_factory, - self._project_dir, - ) - except Exception: - log.debug("evaluate_all.file_overlap_fetch_failed", exc_info=True) - - # Compute alive count once: used for both the slot gate and remaining capacity alive_count = await self._get_alive_count() global_slots: BlockReason | None = None if alive_count >= cfg.max_parallel_agents: @@ -182,57 +235,35 @@ async def evaluate_all(self, *, plan: PlanResult | None = None) -> list[Progress detail=f"All agent slots occupied ({alive_count}/{cfg.max_parallel_agents})", ) remaining_slots = cfg.max_parallel_agents - alive_count - remaining_quota = not bool(global_quota) # True if quota is available - - # When a task queue is configured, evaluate only queued issues (in order), - # skipping blocked items without removing them. Read from self._config - # (SupervisorConfig) which the daemon creates fresh each poll cycle. - task_queue = self._config.task_queue - if task_queue: - node_set = set(graph.nodes) - task_ids: list[int] = [] - skipped: list[int] = [] - for qid in task_queue: - (task_ids if qid in node_set else skipped).append(qid) - if skipped: - log.warning("evaluate_all.queue_items_not_in_graph", skipped=skipped) - else: - task_ids = list(graph.nodes) + remaining_quota = not bool(global_quota) + task_ids = self._resolve_task_ids(graph) tasks = [graph.get_task(nid) for nid in task_ids] decisions: list[ProgressionDecision] = [] for task in tasks: if task is None: continue - issue = int(task.id) - # Re-check rate limit each iteration: if a GitHub API call during - # a prior task's evaluation triggered the tracker, stop making - # further API calls. The precomputed None from line 119 is stale - # once the tracker transitions mid-loop. mid_loop_rate_limit = self._check_github_rate_limit_gate() if mid_loop_rate_limit is not None: - log.info("evaluate_all.mid_loop_rate_limited", issue=issue) + log.info("evaluate_all.mid_loop_rate_limited", issue=int(task.id)) break - # Recompute slot blocker based on remaining capacity - effective_slots = global_slots - if effective_slots is None and remaining_slots <= 0: - effective_slots = BlockReason( - gate="slots", - detail="All agent slots would be occupied (batch capacity exhausted)", - ) - - # Recompute quota blocker based on remaining capacity - effective_quota = global_quota - if effective_quota is None and not remaining_quota: - effective_quota = BlockReason( - gate="quota", - detail="CodeRabbit quota would be exhausted (batch capacity exhausted)", - ) + effective_slots = self._effective_gate( + global_slots, + remaining_slots <= 0, + "slots", + "All agent slots would be occupied", + ) + effective_quota = self._effective_gate( + global_quota, + not remaining_quota, + "quota", + "CodeRabbit quota would be exhausted", + ) decision = await self._evaluate_single( - issue, + int(task.id), task.state, graph, precomputed_memory=global_memory, @@ -248,14 +279,11 @@ async def evaluate_all(self, *, plan: PlanResult | None = None) -> list[Progress ) decisions.append(decision) - # Decrement capacity for actionable decisions - if decision.action not in NON_ACTIONABLE_ACTIONS and decision.action not in ( - ProgressionAction.SPAWN_REBASE, - ProgressionAction.RESET_STALE_STATE, - ): - remaining_slots -= 1 - if decision.action == ProgressionAction.SPAWN_DEVELOPER: - remaining_quota = False + remaining_slots, remaining_quota = self._update_remaining_capacity( + decision, + remaining_slots, + remaining_quota, + ) if plan is not None: approved_set = {(a.action, a.issue) for a in plan.actions} @@ -334,15 +362,8 @@ async def evaluate_task(self, issue_number: int) -> ProgressionDecision: blocked_by=(BlockReason(gate="adapter", detail="build_dependency_graph() failed"),), ) - try: - from sova.dashboard.services.pr_service import get_pr_mergeability_map + precomputed_conflicts = await self._fetch_mergeability_map() - precomputed_conflicts = await get_pr_mergeability_map() - except Exception: - log.debug("evaluate_task.mergeability_fetch_failed", issue=issue_number, exc_info=True) - precomputed_conflicts = {} - - # Fetch task info and file sets for file overlap gate precomputed_file_sets: list[BranchFileSet] | None = None task_labels: list[str] = [] task_body: str = "" @@ -754,6 +775,16 @@ async def _collect_gate_blockers( if dep_block: blockers.append(dep_block) + # Human involvement gate: pure in-memory label check, before API-calling gates. + # Extract labels from graph when caller didn't provide them (fail-closed). + effective_labels = task_labels + if effective_labels is None and graph is not None: + task_node = graph.get_task(issue_number) + effective_labels = task_node.labels if task_node else [] + human_block = self._check_human_involvement_gate(issue_number, effective_labels or []) + if human_block: + blockers.append(human_block) + # Run async per-task gates concurrently (ownership gate returns a tuple) running_result, budget_result, ownership_result = await asyncio.gather( self._check_already_running(issue_number), @@ -923,6 +954,27 @@ def _check_dependency_gate(self, issue: int, graph: DependencyGraph) -> BlockRea return None + def _check_human_involvement_gate(self, issue: int, labels: list[str]) -> BlockReason | None: + """Block issues labeled agent:human-only or agent:interactive-recommended. + + human-only is a hard block (defense-in-depth alongside state-based HUMAN_ONLY). + interactive-recommended blocks autonomous scheduling; manual dashboard starts + bypass the progression engine entirely. + """ + if is_human_only(labels): + log.info("human_involvement_gate.blocked_human_only", issue=issue) + return BlockReason( + gate="human_involvement", + detail=f"Issue #{issue} is labeled agent:human-only", + ) + if is_interactive_recommended(labels): + log.info("human_involvement_gate.blocked_interactive_recommended", issue=issue) + return BlockReason( + gate="human_involvement", + detail=f"Issue #{issue} is labeled agent:interactive-recommended (manual start only)", + ) + return None + async def _check_quota_gate( self, action: ProgressionAction, *, cfg: ProjectConfig | None = None ) -> BlockReason | None: diff --git a/tests/test_dependency_graph.py b/tests/test_dependency_graph.py index 25dbaa5f..01e46bc5 100644 --- a/tests/test_dependency_graph.py +++ b/tests/test_dependency_graph.py @@ -18,6 +18,8 @@ _graph_cache, build_dependency_graph, invalidate_graph_cache, + is_human_only, + is_interactive_recommended, parse_dependencies, ) @@ -1444,3 +1446,83 @@ def test_priority_field_present_when_unlabelled(self) -> None: def test_mixed_labels_extracts_correct_priority(self) -> None: task = self._task_with_labels(6, ["area: dashboard", "priority: low", "type: feature"]) assert DependencyGraph([task]).to_dict()["nodes"][0]["priority"] == "low" + + +# --------------------------------------------------------------------------- +# is_human_only / is_interactive_recommended label helpers +# --------------------------------------------------------------------------- + + +class TestIsHumanOnly: + def test_no_labels(self) -> None: + assert is_human_only([]) is False + + def test_exact_match(self) -> None: + assert is_human_only(["agent:human-only"]) is True + + def test_case_insensitive(self) -> None: + assert is_human_only(["Agent:Human-Only"]) is True + + def test_with_spaces(self) -> None: + assert is_human_only([" agent:human-only "]) is True + + def test_unrelated_labels(self) -> None: + assert is_human_only(["area: dashboard", "priority: high"]) is False + + def test_among_other_labels(self) -> None: + assert is_human_only(["type: feature", "agent:human-only", "priority: low"]) is True + + +class TestIsInteractiveRecommended: + def test_no_labels(self) -> None: + assert is_interactive_recommended([]) is False + + def test_exact_match(self) -> None: + assert is_interactive_recommended(["agent:interactive-recommended"]) is True + + def test_case_insensitive(self) -> None: + assert is_interactive_recommended(["Agent:Interactive-Recommended"]) is True + + def test_with_spaces(self) -> None: + assert is_interactive_recommended([" agent:interactive-recommended "]) is True + + def test_unrelated_labels(self) -> None: + assert is_interactive_recommended(["area: dashboard", "agent:human-only"]) is False + + +# --------------------------------------------------------------------------- +# to_dict -- is_human_only / is_interactive_recommended fields +# --------------------------------------------------------------------------- + + +class TestToDictHumanInvolvementFlags: + def test_no_labels_both_false(self) -> None: + tasks = [_task(1)] + node = DependencyGraph(tasks).to_dict()["nodes"][0] + assert node["is_human_only"] is False + assert node["is_interactive_recommended"] is False + + def test_human_only_label_sets_flag(self) -> None: + tasks = [_task(1, labels=["agent:human-only"])] + node = DependencyGraph(tasks).to_dict()["nodes"][0] + assert node["is_human_only"] is True + assert node["is_interactive_recommended"] is False + + def test_interactive_recommended_label_sets_flag(self) -> None: + tasks = [_task(1, labels=["agent:interactive-recommended"])] + node = DependencyGraph(tasks).to_dict()["nodes"][0] + assert node["is_human_only"] is False + assert node["is_interactive_recommended"] is True + + def test_both_labels_human_only_takes_precedence(self) -> None: + tasks = [_task(1, labels=["agent:human-only", "agent:interactive-recommended"])] + node = DependencyGraph(tasks).to_dict()["nodes"][0] + assert node["is_human_only"] is True + assert node["is_interactive_recommended"] is False + + def test_flags_present_alongside_existing_fields(self) -> None: + tasks = [_task(1, labels=["type: epic", "agent:human-only", "priority: high"])] + node = DependencyGraph(tasks).to_dict()["nodes"][0] + assert node["is_epic"] is True + assert node["is_human_only"] is True + assert node["priority"] == "high" diff --git a/tests/test_progression.py b/tests/test_progression.py index 8932b8b5..9933944a 100644 --- a/tests/test_progression.py +++ b/tests/test_progression.py @@ -658,6 +658,71 @@ async def test_adapter_failure_returns_blocked(self) -> None: assert decision.action == ProgressionAction.BLOCKED assert "Failed to fetch" in decision.reason + @pytest.mark.asyncio + async def test_human_only_label_blocks_progression(self) -> None: + adapter = AsyncMock() + adapter.get_state = AsyncMock(return_value=TaskState.TRIAGED) + adapter.list_tasks = AsyncMock(return_value=[_task(1, state=TaskState.TRIAGED, labels=["agent:human-only"])]) + engine = _make_engine( + config=SupervisorConfig(auto_research=True), + adapter=adapter, + ) + with ( + patch.object(engine, "_check_already_running", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_quota_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_slot_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_budget_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_ownership_gate", new_callable=AsyncMock, return_value=(None, None)), + ): + decision = await engine.evaluate_task(1) + assert decision.action == ProgressionAction.BLOCKED + assert any(b.gate == "human_involvement" for b in decision.blocked_by) + + @pytest.mark.asyncio + async def test_interactive_recommended_label_blocks_autonomous(self) -> None: + adapter = AsyncMock() + adapter.get_state = AsyncMock(return_value=TaskState.RESEARCHED) + adapter.list_tasks = AsyncMock( + return_value=[_task(1, state=TaskState.RESEARCHED, labels=["agent:interactive-recommended"])] + ) + engine = _make_engine( + config=SupervisorConfig(auto_develop=True), + adapter=adapter, + ) + with ( + patch.object(engine, "_check_already_running", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_quota_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_slot_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_budget_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_ownership_gate", new_callable=AsyncMock, return_value=(None, None)), + ): + decision = await engine.evaluate_task(1) + assert decision.action == ProgressionAction.BLOCKED + assert any(b.gate == "human_involvement" for b in decision.blocked_by) + + @pytest.mark.asyncio + async def test_human_only_gate_extracts_labels_from_graph_when_none(self) -> None: + """When task_labels=None, _collect_gate_blockers extracts labels from the graph.""" + from sova.supervisor.dependency_graph import DependencyGraph + + tasks = [_task(1, state=TaskState.TRIAGED, labels=["agent:human-only"])] + graph = DependencyGraph(tasks) + engine = _make_engine( + config=SupervisorConfig(auto_research=True, respect_dependencies=False), + ) + with ( + patch.object(engine, "_check_already_running", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_budget_gate", new_callable=AsyncMock, return_value=None), + patch.object(engine, "_check_ownership_gate", new_callable=AsyncMock, return_value=(None, None)), + ): + blockers, _ = await engine._collect_gate_blockers( + 1, + ProgressionAction.SPAWN_RESEARCHER, + graph, + task_labels=None, + ) + assert any(b.gate == "human_involvement" for b in blockers) + # --------------------------------------------------------------------------- # evaluate_all @@ -3044,6 +3109,50 @@ async def test_api_error_zero_budget_fails_open(self, mock_cfg: MagicMock, mock_ assert result is None +# --------------------------------------------------------------------------- +# _check_human_involvement_gate +# --------------------------------------------------------------------------- + + +class TestHumanInvolvementGate: + def test_no_labels_passes(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, []) + assert result is None + + def test_unrelated_labels_pass(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, ["area: dashboard", "priority: high"]) + assert result is None + + def test_human_only_label_blocks(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, ["agent:human-only"]) + assert result is not None + assert result.gate == "human_involvement" + assert "human-only" in result.detail + + def test_interactive_recommended_label_blocks(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, ["agent:interactive-recommended"]) + assert result is not None + assert result.gate == "human_involvement" + assert "interactive-recommended" in result.detail + assert "manual start only" in result.detail + + def test_both_labels_blocks_human_only(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, ["agent:human-only", "agent:interactive-recommended"]) + assert result is not None + assert "human-only" in result.detail + + def test_case_insensitive(self) -> None: + engine = _make_engine() + result = engine._check_human_involvement_gate(1, ["Agent:Human-Only"]) + assert result is not None + assert result.gate == "human_involvement" + + class TestExecuteDecisionsCap: @pytest.mark.asyncio async def test_cap_limits_spawns(self) -> None: @@ -3267,3 +3376,138 @@ async def test_evaluate_task_in_progress_returns_reset(self) -> None: decision = await engine.evaluate_task(1) assert decision.action == ProgressionAction.RESET_STALE_STATE assert "Stale IN_PROGRESS" in decision.reason + + +# --------------------------------------------------------------------------- +# Extracted helpers from evaluate_all refactoring +# --------------------------------------------------------------------------- + + +_MERGEABILITY_PATH = "sova.dashboard.services.pr_service.get_pr_mergeability_map" + + +class TestFetchMergeabilityMap: + @pytest.mark.asyncio + async def test_returns_map_on_success(self) -> None: + engine = _make_engine() + expected = {42: "CONFLICTING"} + with patch(_MERGEABILITY_PATH, new_callable=AsyncMock, return_value=expected): + result = await engine._fetch_mergeability_map() + assert result == expected + + @pytest.mark.asyncio + async def test_returns_empty_dict_on_exception(self) -> None: + engine = _make_engine() + with patch(_MERGEABILITY_PATH, new_callable=AsyncMock, side_effect=RuntimeError("API down")): + result = await engine._fetch_mergeability_map() + assert result == {} + + +_FILE_OVERLAP_PATH = "sova.supervisor.progression.get_active_branch_file_sets" + + +class TestFetchFileOverlapSets: + @pytest.mark.asyncio + async def test_returns_none_when_gate_disabled(self) -> None: + engine = _make_engine(config=SupervisorConfig(file_overlap_gate=False)) + result = await engine._fetch_file_overlap_sets() + assert result is None + + @pytest.mark.asyncio + async def test_returns_sets_when_gate_enabled(self) -> None: + engine = _make_engine(config=SupervisorConfig(file_overlap_gate=True)) + sentinel = [MagicMock()] + with patch(_FILE_OVERLAP_PATH, new_callable=AsyncMock, return_value=sentinel): + result = await engine._fetch_file_overlap_sets() + assert result is sentinel + + @pytest.mark.asyncio + async def test_returns_none_on_exception(self) -> None: + engine = _make_engine(config=SupervisorConfig(file_overlap_gate=True)) + with patch(_FILE_OVERLAP_PATH, new_callable=AsyncMock, side_effect=RuntimeError("fail")): + result = await engine._fetch_file_overlap_sets() + assert result is None + + +class TestResolveTaskIds: + def test_no_queue_returns_all_nodes(self) -> None: + engine = _make_engine(config=SupervisorConfig(task_queue=[])) + graph = MagicMock() + graph.nodes = {1: None, 2: None, 3: None} + result = engine._resolve_task_ids(graph) + assert set(result) == {1, 2, 3} + + def test_queue_filters_to_graph_nodes(self) -> None: + engine = _make_engine(config=SupervisorConfig(task_queue=[10, 20, 30])) + graph = MagicMock() + graph.nodes = {10: None, 30: None} + result = engine._resolve_task_ids(graph) + assert result == [10, 30] + + def test_queue_preserves_order(self) -> None: + engine = _make_engine(config=SupervisorConfig(task_queue=[30, 10, 20])) + graph = MagicMock() + graph.nodes = {10: None, 20: None, 30: None} + result = engine._resolve_task_ids(graph) + assert result == [30, 10, 20] + + +class TestEffectiveGate: + def test_returns_global_blocker_when_present(self) -> None: + blocker = BlockReason(gate="slots", detail="full") + result = TaskProgressionEngine._effective_gate(blocker, True, "slots", "msg") + assert result is blocker + + def test_returns_exhaustion_blocker_when_capacity_spent(self) -> None: + result = TaskProgressionEngine._effective_gate(None, True, "quota", "CodeRabbit quota would be exhausted") + assert result is not None + assert result.gate == "quota" + assert "capacity exhausted" in result.detail + + def test_returns_none_when_capacity_available(self) -> None: + result = TaskProgressionEngine._effective_gate(None, False, "slots", "msg") + assert result is None + + +class TestUpdateRemainingCapacity: + def test_actionable_decrements_slots(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.SPAWN_RESEARCHER) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 2 + assert quota is True + + def test_developer_decrements_quota(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.SPAWN_DEVELOPER) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 2 + assert quota is False + + def test_wait_does_not_decrement(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.WAIT) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 3 + assert quota is True + + def test_blocked_does_not_decrement(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.BLOCKED) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 3 + assert quota is True + + def test_rebase_does_not_decrement(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.SPAWN_REBASE) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 3 + assert quota is True + + def test_checkpoint_does_not_decrement(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.CHECKPOINT_NEEDED) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 3 + assert quota is True + + def test_reset_stale_state_does_not_decrement(self) -> None: + decision = ProgressionDecision(issue_number=1, action=ProgressionAction.RESET_STALE_STATE) + slots, quota = TaskProgressionEngine._update_remaining_capacity(decision, 3, True) + assert slots == 3 + assert quota is True From 8e7d55ada416c76625a845f70ee2ee7f4cbf555c Mon Sep 17 00:00:00 2001 From: Damian Sova Date: Fri, 14 Aug 2026 16:12:14 +0200 Subject: [PATCH 2/4] feat(dashboard): render human-required and interactive-recommended badges Graph node annotations expose labels to the frontend via the /supervisor/graph API. human-only nodes get a red tint, interactive-recommended get an orange tint with dashed borders. Server-side label population in both CLI and scheduler server graph endpoints. --- sova/cli/commands/server.py | 12 +++-- sova/dashboard/templates/supervisor.html | 63 +++++++++++++++++++++--- sova/scheduler/server.py | 29 ++++++++--- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/sova/cli/commands/server.py b/sova/cli/commands/server.py index 4975390b..6d527769 100644 --- a/sova/cli/commands/server.py +++ b/sova/cli/commands/server.py @@ -40,7 +40,7 @@ def start( multi = project is None and has_projects() # Check if already running - existing_pid = read_pid_file(config) + existing_pid = read_pid_file(config, project_dir=resolved_dir) if existing_pid is not None: console.print(f"[yellow]Server already running (PID {existing_pid}).[/yellow]") raise typer.Exit(code=1) @@ -77,9 +77,10 @@ def stop( from sova.config.loader import load_config from sova.scheduler.server import stop_server - config = load_config(project) if project else None + resolved_dir = project or Path.cwd() + config = load_config(resolved_dir) - if stop_server(config): + if stop_server(config, project_dir=resolved_dir): console.print("[green]Server stopped.[/green]") else: console.print("[yellow]Server is not running.[/yellow]") @@ -93,8 +94,9 @@ def status( from sova.config.loader import load_config from sova.scheduler.server import read_pid_file - config = load_config(project) if project else None - pid = read_pid_file(config) + resolved_dir = project or Path.cwd() + config = load_config(resolved_dir) + pid = read_pid_file(config, project_dir=resolved_dir) if pid is not None: console.print(f"[green]Server is running (PID {pid}).[/green]") diff --git a/sova/dashboard/templates/supervisor.html b/sova/dashboard/templates/supervisor.html index 7bc7164d..26b2e499 100644 --- a/sova/dashboard/templates/supervisor.html +++ b/sova/dashboard/templates/supervisor.html @@ -40,6 +40,9 @@

Dependency Graph High Med Low + | + Human + Interactive

function _computeGraphKey(data) { // agent_elapsed_seconds is intentionally excluded: it changes every poll while an agent runs // and would cause a full re-render (and position reset) on every poll cycle. - const n = (data.nodes || []).map(n => `${n.id}:${n.state}:${n.milestone||''}:${n.pr_state||''}:${n.agent_running?1:0}:${n.agent_run_id||''}:${n.agent_role||''}:${n.handoff_pending?1:0}:${n.handoff_action||''}:${n.last_run_status||''}:${n.last_run_id||''}:${n.priority||''}`).sort().join(','); + const n = (data.nodes || []).map(n => `${n.id}:${n.state}:${n.milestone||''}:${n.pr_state||''}:${n.agent_running?1:0}:${n.agent_run_id||''}:${n.agent_role||''}:${n.handoff_pending?1:0}:${n.handoff_action||''}:${n.last_run_status||''}:${n.last_run_id||''}:${n.priority||''}:${n.is_human_only?1:0}:${n.is_interactive_recommended?1:0}`).sort().join(','); const e = (data.edges || []).map(e => `${e.from}:${e.to}`).sort().join(','); return n + '|' + e; } @@ -1009,18 +1012,28 @@

Setup Required

const prc = n.pr_state ? prStateColor(n.pr_state) : null; const borderColor = prc || (color + '66'); + const isEpic = n.is_epic === true; + const isHumanOnly = n.is_human_only === true; + const isInteractive = n.is_interactive_recommended === true; + + const humanBorder = isHumanOnly ? '#f38ba8' : (isInteractive ? '#fab387' : null); + const effectiveBorder = humanBorder || borderColor; + const g = mainG.append('g') .attr('class', 'dep-node') - .attr('data-nid', n.id).attr('data-color', color).attr('data-border', borderColor).attr('data-done', isDone ? '1' : '0') + .attr('data-nid', n.id).attr('data-color', color).attr('data-border', effectiveBorder).attr('data-done', isDone ? '1' : '0') .attr('opacity', isDone ? 0.35 : 1) .style('cursor', 'pointer'); - - const isEpic = n.is_epic === true; const bgRect = g.append('rect').attr('class', 'node-bg') .attr('x', pos.x).attr('y', pos.y).attr('width', NW).attr('height', NH).attr('rx', 5) - .attr('fill', color + '18').attr('stroke', borderColor).attr('stroke-width', prc ? 1.5 : (isEpic ? 1.5 : 1)); - if (isEpic) { + .attr('fill', isHumanOnly ? '#f38ba810' : (isInteractive ? '#fab38710' : color + '18')) + .attr('stroke', effectiveBorder).attr('stroke-width', (humanBorder || prc) ? 1.5 : (isEpic ? 1.5 : 1)); + if (isEpic && isInteractive) { + bgRect.attr('stroke-dasharray', '5,2,2,2'); + } else if (isEpic) { bgRect.attr('stroke-dasharray', '4,3'); + } else if (isInteractive) { + bgRect.attr('stroke-dasharray', '6,3'); } // Priority left-border stripe (3px, clipped to node corner radius) + arrow icon @@ -1062,6 +1075,35 @@

Setup Required

.text('EPIC'); } + // Human-only / interactive-recommended badge at bottom-left (after epic badge if present) + if (isHumanOnly) { + const HUMAN_BADGE_COLOR = '#f38ba8'; + const HUMAN_BADGE_W = 42; + const hbX = isEpic ? pos.x + 4 + EPIC_BADGE_WIDTH + 4 : pos.x + 4; + const hbY = pos.y + NH - 18; + g.append('rect') + .attr('x', hbX).attr('y', hbY).attr('width', HUMAN_BADGE_W).attr('height', EPIC_BADGE_HEIGHT).attr('rx', 7) + .attr('fill', HUMAN_BADGE_COLOR).attr('fill-opacity', 0.25).attr('stroke', HUMAN_BADGE_COLOR).attr('stroke-width', 0.5); + g.append('text') + .attr('x', hbX + HUMAN_BADGE_W/2).attr('y', hbY + 10) + .attr('text-anchor', 'middle') + .attr('fill', HUMAN_BADGE_COLOR).attr('font-size', '7px').attr('font-weight', '600').attr('font-family', 'monospace') + .text('HUMAN'); + } else if (isInteractive) { + const INTER_BADGE_COLOR = '#fab387'; + const INTER_BADGE_W = 60; + const ibX = isEpic ? pos.x + 4 + EPIC_BADGE_WIDTH + 4 : pos.x + 4; + const ibY = pos.y + NH - 18; + g.append('rect') + .attr('x', ibX).attr('y', ibY).attr('width', INTER_BADGE_W).attr('height', EPIC_BADGE_HEIGHT).attr('rx', 7) + .attr('fill', INTER_BADGE_COLOR).attr('fill-opacity', 0.2).attr('stroke', INTER_BADGE_COLOR).attr('stroke-width', 0.5).attr('stroke-dasharray', '3,2'); + g.append('text') + .attr('x', ibX + INTER_BADGE_W/2).attr('y', ibY + 10) + .attr('text-anchor', 'middle') + .attr('fill', INTER_BADGE_COLOR).attr('font-size', '7px').attr('font-weight', '600').attr('font-family', 'monospace') + .text('INTERACTIVE'); + } + g.append('circle').attr('cx', pos.x + 12).attr('cy', pos.y + 17).attr('r', 4).attr('fill', color); g.append('text') @@ -1119,11 +1161,11 @@

Setup Required

g.on('mouseenter', function() { if (+this.dataset.nid !== _selectedNodeId) - d3.select(this).select('rect.node-bg').attr('stroke', prc || (color + 'bb')); + d3.select(this).select('rect.node-bg').attr('stroke', humanBorder || prc || (color + 'bb')); }) .on('mouseleave', function() { if (+this.dataset.nid !== _selectedNodeId) - d3.select(this).select('rect.node-bg').attr('stroke', borderColor); + d3.select(this).select('rect.node-bg').attr('stroke', effectiveBorder); }) .on('click', event => { event.stopPropagation(); @@ -1175,6 +1217,11 @@

Setup Required

const existingBadges = document.getElementById('drawer-status-badges'); if (existingBadges) existingBadges.remove(); let statusBadges = ''; + if (node.is_human_only) { + statusBadges += `Human Only`; + } else if (node.is_interactive_recommended) { + statusBadges += `Interactive Recommended`; + } if (node.agent_running) { const agentLink = node.agent_run_id != null ? `` : ''; const agentLinkEnd = node.agent_run_id != null ? '' : ''; diff --git a/sova/scheduler/server.py b/sova/scheduler/server.py index 25fa387b..4ef1ebc8 100644 --- a/sova/scheduler/server.py +++ b/sova/scheduler/server.py @@ -191,8 +191,7 @@ def _pid_file_path(self) -> Path: """Resolve the PID file path.""" if self._config.server.pid_file: return Path(self._config.server.pid_file) - _DEFAULT_PID_DIR.mkdir(parents=True, exist_ok=True) - return _DEFAULT_PID_DIR / "sova-server.pid" + return _resolve_default_pid_path(self._project_dir) def _write_pid_file(self) -> None: """Write the current PID to the PID file.""" @@ -209,7 +208,21 @@ def _remove_pid_file(self) -> None: log.warning("pid.remove_failed", path=str(pid_path), exc_info=True) -def read_pid_file(config: ProjectConfig | None = None) -> int | None: +def _resolve_default_pid_path(project_dir: Path | None = None) -> Path: + """Derive the default PID file path, scoped to project_dir when available.""" + if project_dir is not None: + claude_dir = project_dir / ".claude" + if claude_dir.is_dir(): + return claude_dir / "sova-server.pid" + _DEFAULT_PID_DIR.mkdir(parents=True, exist_ok=True) + return _DEFAULT_PID_DIR / "sova-server.pid" + + +def read_pid_file( + config: ProjectConfig | None = None, + *, + project_dir: Path | None = None, +) -> int | None: """Read the server PID from the PID file. Returns the PID if the file exists and the process is alive, else None. @@ -217,7 +230,7 @@ def read_pid_file(config: ProjectConfig | None = None) -> int | None: if config and config.server.pid_file: pid_path = Path(config.server.pid_file) else: - pid_path = _DEFAULT_PID_DIR / "sova-server.pid" + pid_path = _resolve_default_pid_path(project_dir) if not pid_path.exists(): return None @@ -240,12 +253,16 @@ def read_pid_file(config: ProjectConfig | None = None) -> int | None: return None -def stop_server(config: ProjectConfig | None = None) -> bool: +def stop_server( + config: ProjectConfig | None = None, + *, + project_dir: Path | None = None, +) -> bool: """Send SIGTERM to the running server process. Returns True if a signal was sent, False if no server was running. """ - pid = read_pid_file(config) + pid = read_pid_file(config, project_dir=project_dir) if pid is None: return False From 1a6fa662f1d3b775324129c39d93481f4a333dfb Mon Sep 17 00:00:00 2001 From: Damian Sova Date: Fri, 14 Aug 2026 16:12:35 +0200 Subject: [PATCH 3/4] fix(dashboard): support per-PR merge queue marker files Change merge queue marker files from shared merge-queue.json to per-PR merge-queue-{N}.json naming, matching the handoff-{issue}.json pattern. This eliminates race conditions when multiple PRs enter the merge queue concurrently. _check_merge_queue_marker_file now globs for per-PR files with legacy fallback. --- .claude/commands/approve-merge.md | 2 +- .claude/commands/integrate-pr.md | 2 +- .claude/rules/architecture.md | 2 +- commands/integrate-pr.md | 2 +- sova/dashboard/services/agent_lifecycle.py | 73 +++++++++++++--------- 5 files changed, 46 insertions(+), 35 deletions(-) diff --git a/.claude/commands/approve-merge.md b/.claude/commands/approve-merge.md index 5aaf08e7..a4d62772 100644 --- a/.claude/commands/approve-merge.md +++ b/.claude/commands/approve-merge.md @@ -116,7 +116,7 @@ If merge queue is detected (or forced via config): - Write a merge queue marker file so the dashboard can track the PR: ```bash mkdir -p .claude/agent-control - python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue.json + python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue-.json ``` - Proceed to queue polling (step 3b) diff --git a/.claude/commands/integrate-pr.md b/.claude/commands/integrate-pr.md index a8e2f5e2..a40a1a72 100644 --- a/.claude/commands/integrate-pr.md +++ b/.claude/commands/integrate-pr.md @@ -171,7 +171,7 @@ If merge queue is detected: - If enqueued, write a merge queue marker file so the dashboard can track the PR: ```bash mkdir -p .claude/agent-control - python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue.json + python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue-.json ``` - Then poll merge queue status via GraphQL every `merge_queue_poll_interval` seconds (default 30) - On MERGED: proceed to Phase 6. If `delete_branch = true`, delete remote branch via GitHub API diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 552062ec..a13fdb5f 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -150,7 +150,7 @@ The project's full name is **SOVA** (Software Orchestration Via Agents). - **New config sections need triple registration**: adding a Pydantic model to `ProjectConfig` requires: (1) model class in `sova/config/models.py`, (2) section name in `_NESTED_SECTIONS` in `sova/config/loader.py` (without this, `[section]` in sova.toml is silently ignored), (3) metadata in `sova/dashboard/settings_meta.py` for UI display. Caveat: `_NESTED_SECTIONS` passes TOML values as init kwargs, which outrank `SOVA_*` env vars in Pydantic Settings. - **Per-issue handoff files for parallel agent isolation**: agents write to `handoff-{issue}.json` instead of a shared `handoff.json`. This eliminates race conditions when multiple agents finish concurrently. `write_handoff_file` uses `handoff.issue` for the filename. `read_handoff_file(issue=N)` reads a specific file; without issue, returns the most recent. `read_all_handoff_files` returns all active handoffs. All readers (`_process_auto_handoff`, `_load_review_findings`, `_read_file_handoff`) pass issue context. The `GET /handoff` API returns `{handoff, handoffs[]}` for backward compatibility. Legacy `handoff.json` is supported as a fallback. Files: `sova/ipc/handoff.py`, `sova/dashboard/services/handoff_service.py`. - **`recover_stale_runs` must check external state for merge-role runs**: when `recover_stale_runs()` finds a dead-PID run with role in `{integrate-pr, approve-merge}` and a `pr_number`, check `_check_pr_merged_on_failure` before marking interrupted. If the PR is merged, mark as "done" instead. Prevents false "interrupted" banners on dashboard restart after the agent completed its work but the process died during cleanup. File: `sova/dashboard/services/agent_recovery.py`. -- **Background merge queue monitor**: `MergeQueueMonitor` dataclass in `sova/dashboard/services/merge_queue_monitor.py` polls `MergeQueueEntry` DB records for PRs enqueued in GitHub merge queues. Runs as background asyncio tasks in dashboard lifespan (one per registered project). Two entry-creation paths: (1) `_check_merge_queue_on_failure()` in `agent_lifecycle.py` when `_wait_and_finalize()` detects a merge-role agent exiting with a PR still in queue, (2) `_check_merge_queue_marker_file()` reads `.claude/agent-control/merge-queue.json` written by `/integrate-pr` and `/approve-merge` commands. Monitor handles four states: merged (post-merge cleanup: branch delete, issue transition, worktree removal, feed event, notification), ejected (feed event, notification), timeout (configurable via `merge_queue_timeout`), still-queued (log with position). Rate-limit aware via `get_github_quota_tracker()`. NOT_QUEUED fallback checks `_check_pr_merged_directly()` since PRs may merge between polls. Config: `IntegrationConfig` fields `merge_queue_enabled` (str, "true"/"false"), `merge_queue_poll_interval` (int, default 120s), `merge_queue_timeout` (int, default 3600s). Migration 029 adds `merge_queue_entries` table. PR #653. +- **Background merge queue monitor**: `MergeQueueMonitor` dataclass in `sova/dashboard/services/merge_queue_monitor.py` polls `MergeQueueEntry` DB records for PRs enqueued in GitHub merge queues. Runs as background asyncio tasks in dashboard lifespan (one per registered project). Two entry-creation paths: (1) `_check_merge_queue_on_failure()` in `agent_lifecycle.py` when `_wait_and_finalize()` detects a merge-role agent exiting with a PR still in queue, (2) `_check_merge_queue_marker_file()` globs `.claude/agent-control/merge-queue-*.json` (per-PR naming, matching the `handoff-{issue}.json` pattern) written by `/integrate-pr` and `/approve-merge` commands, with legacy `merge-queue.json` fallback. Monitor handles four states: merged (post-merge cleanup: branch delete, issue transition, worktree removal, feed event, notification), ejected (feed event, notification), timeout (configurable via `merge_queue_timeout`), still-queued (log with position). Rate-limit aware via `get_github_quota_tracker()`. NOT_QUEUED fallback checks `_check_pr_merged_directly()` since PRs may merge between polls. Config: `IntegrationConfig` fields `merge_queue_enabled` (str, "true"/"false"), `merge_queue_poll_interval` (int, default 120s), `merge_queue_timeout` (int, default 3600s). Migration 029 adds `merge_queue_entries` table. PR #653. - **Config-driven provider selection must be wired at startup**: adding a config field (`llm.provider`) + factory (`create_provider()`) + ABC without calling `set_provider(create_provider(cfg.llm.provider))` at app startup means the config has no effect. Wire init in `_init_llm_provider()` (CLI) and `create_app()` (dashboard). For multi-project mode, gate behind `if not is_multi:` or resolve per-request. Files: `sova/llm/provider.py`, `sova/cli/app.py`, `sova/dashboard/app.py`. - **`pull_request_target` reads workflow from base branch, not PR branch**: GitHub's `pull_request_target` event always uses the workflow YAML from the base branch (e.g., `main`), even for fork PRs. This makes it tamper-proof -- fork authors cannot modify the workflow. Use it for security-sensitive automation like auto-approving CI runs, posting status comments, or running SonarCloud scans with secrets. The `GITHUB_TOKEN` in `pull_request_target` context has write permissions (unlike `pull_request` for forks, which is read-only). Caveat: CI workflow changes in a PR don't take effect for that PR's own `pull_request_target` run -- they only apply after merge. Fork-owner conditions (`github.event.pull_request.author_association`) should be used to scope actions to external contributors. Files: `.github/workflows/fork-pr-gate.yml`, `.github/workflows/sonarcloud.yml`. diff --git a/commands/integrate-pr.md b/commands/integrate-pr.md index f679de73..b126f2ed 100644 --- a/commands/integrate-pr.md +++ b/commands/integrate-pr.md @@ -166,7 +166,7 @@ If merge queue is detected: - If enqueued, write a merge queue marker file so the dashboard can track the PR: ```bash mkdir -p .claude/agent-control - python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue.json + python3 -c "import json; print(json.dumps({'pr_number': , 'repo': '', 'issue_number': '', 'branch_name': ''}))" > .claude/agent-control/merge-queue-.json ``` - Then poll merge queue status via GraphQL every `merge_queue_poll_interval` seconds (default 30) - On MERGED: proceed to Phase 6. If `delete_branch = true`, delete remote branch via GitHub API diff --git a/sova/dashboard/services/agent_lifecycle.py b/sova/dashboard/services/agent_lifecycle.py index f41d42f4..3418b1a6 100644 --- a/sova/dashboard/services/agent_lifecycle.py +++ b/sova/dashboard/services/agent_lifecycle.py @@ -977,46 +977,57 @@ async def _check_merge_queue_marker_file(agent: AgentState, run_id: int | None) if project_dir is None: return - marker_path = Path(project_dir) / ".claude" / "agent-control" / "merge-queue.json" - if not marker_path.exists(): + control_dir = Path(project_dir) / ".claude" / "agent-control" + if not control_dir.exists(): return - try: - data = json.loads(marker_path.read_text()) - pr_number = data.get("pr_number") - repo = data.get("repo", "") - issue_number = data.get("issue_number") - branch_name = data.get("branch_name", "") + # Support both per-PR naming (merge-queue-{N}.json) and legacy shared file + marker_paths = list(control_dir.glob("merge-queue-*.json")) + legacy_path = control_dir / "merge-queue.json" + if legacy_path.exists(): + marker_paths.append(legacy_path) - if not pr_number or not repo: - return + if not marker_paths: + return - from sova.dashboard.services.merge_queue_monitor import create_merge_queue_entry + for marker_path in marker_paths: + try: + data = json.loads(marker_path.read_text()) + pr_number = data.get("pr_number") + repo = data.get("repo", "") + issue_number = data.get("issue_number") + branch_name = data.get("branch_name", "") + + if not pr_number or not repo: + marker_path.unlink(missing_ok=True) + continue - await create_merge_queue_entry( - pr_number=int(pr_number), - repo=repo, - project_dir=project_dir, - issue_number=str(issue_number) if issue_number else None, - task_run_id=run_id, - github_user=data.get("github_user", ""), - branch_name=branch_name, - ) + from sova.dashboard.services.merge_queue_monitor import create_merge_queue_entry - from sova.dashboard.services.feed_service import FeedEventSeverity, emit_safe + await create_merge_queue_entry( + pr_number=int(pr_number), + repo=repo, + project_dir=project_dir, + issue_number=str(issue_number) if issue_number else None, + task_run_id=run_id, + github_user=data.get("github_user", ""), + branch_name=branch_name, + ) - emit_safe( - f"PR #{pr_number} enqueued in merge queue (monitored)", - severity=FeedEventSeverity.info, - category="merge_queue", - metadata={"pr_number": pr_number, "repo": repo}, - ) + from sova.dashboard.services.feed_service import FeedEventSeverity, emit_safe - log.info("finalize.merge_queue_marker_processed", pr=pr_number, repo=repo) + emit_safe( + f"PR #{pr_number} enqueued in merge queue (monitored)", + severity=FeedEventSeverity.info, + category="merge_queue", + metadata={"pr_number": pr_number, "repo": repo}, + ) - marker_path.unlink(missing_ok=True) - except Exception: - log.debug("finalize.merge_queue_marker_failed", exc_info=True) + log.info("finalize.merge_queue_marker_processed", pr=pr_number, repo=repo) + + marker_path.unlink(missing_ok=True) + except Exception: + log.debug("finalize.merge_queue_marker_failed", path=str(marker_path), exc_info=True) async def _wait_with_terminal_check(agent: AgentState) -> int: From b7d16a294988e074f0f2178db10b9965f36341e1 Mon Sep 17 00:00:00 2001 From: Damian Sova Date: Fri, 14 Aug 2026 16:12:44 +0200 Subject: [PATCH 4/4] fix(commands): improve health-audit robustness and agent configuration Specify subagent_type for agent spawning, add graceful handling for partial agent failures, clarify check discovery per project type, and simplify milestone creation step when target does not exist. --- .claude/commands/health-audit.md | 45 ++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/.claude/commands/health-audit.md b/.claude/commands/health-audit.md index 391033c8..8e0435be 100644 --- a/.claude/commands/health-audit.md +++ b/.claude/commands/health-audit.md @@ -91,17 +91,12 @@ grep -rc "def test_" tests/ apps/*/tests/ 2>/dev/null | awk -F: '{s+=$2} END {pr ### Step 3: Run checks -Run the project's test and lint commands to verify current health: - -```bash -{{ check_cmd }} -``` - -If `{{ check_cmd }}` is not configured, look for `Makefile`, `package.json` -scripts, or CI config to find the right commands. Common fallbacks: -- `make test` + `make lint` -- `npm test` + `npm run lint` -- `cargo test` + `cargo clippy` +Run the project's test and lint commands to verify current health. Discover +the right commands by checking `Makefile`, `package.json` scripts, or CI +config. Common commands by project type: +- Python: `make check`, or `make test` + `make lint` +- Node: `npm test` + `npm run lint` +- Rust: `cargo test` + `cargo clippy` ### Step 4: Incremental mode check @@ -134,12 +129,17 @@ produce all 10 scorecard dimensions but score non-focus areas based on Step 2 discovery only (no deep read). Mark non-focus scores as "(surface-level)" in the scorecard. -**For full audits**, spawn these 3 agents in a single message (so they run -concurrently): +**For full audits**, call the Agent tool 3 times in a single message (so they +run concurrently). Use `subagent_type="general-purpose"` for all three. Include +the project context from Step 2 (name, tech stack, scale, stage) in each prompt. + +If any agent fails or times out, proceed with the available results. Mark the +report as partial and note which dimension(s) were not analyzed. Score missing +dimensions as "(not analyzed)" in the scorecard. #### Agent A: Architecture and Security -Prompt the agent with the project context from Step 2 (tech stack, scale, stage) -and instruct it to analyze: +Include the project context from Step 2 (tech stack, scale, stage) +and instruct the agent to analyze: - Dependency graph health (circular imports between modules) - Data model integrity (schema design, migration hygiene, index coverage) - Security architecture (auth, input validation, secret handling, injection @@ -182,9 +182,10 @@ as Agents A and B. Maximum 20 findings. List 3-5 operational strengths. ### Step 6: Synthesize and verify -After all 3 agents complete: +After the agents complete (or partially fail): -1. **Collect** all findings from the 3 agents. +1. **Collect** all findings from the completed agents. If any agent failed, + note the gap and continue with available results. 2. **Deduplicate** -- findings may overlap (e.g., both Agent A and Agent B flag the same god file). Merge duplicates, keeping the most detailed evidence. 3. **Verify** -- spot-check 5-10 key findings with targeted `grep`, `wc -l`, or @@ -210,6 +211,9 @@ gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)"' Only use labels and milestones that exist. If an appropriate label does not exist, either create it with `gh label create` or use the closest existing one. +If the target milestone for issue creation does not exist in the list, create it +with `gh api repos/:owner/:repo/milestones -X POST -f title=""` before +Phase C. Verify the milestone number is valid before using `--milestone`. ## Analysis Levels @@ -429,6 +433,13 @@ Log completion and cost: bash .claude/benchmark/log.sh "health_audit_complete" "" "" 2>/dev/null || true ``` +On any unrecoverable error at any step (agent spawn failure, `gh` command error, +etc.), log the failure before stopping: + +```bash +bash .claude/benchmark/log.sh "health_audit_failed" "" "" 2>/dev/null || true +``` + ## Constraints - Do NOT fabricate findings. If you are unsure whether an issue exists, say so and explain what you would check.