diff --git a/agents/runner.py b/agents/runner.py index 2c2f66e..a95bfe6 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -76,7 +76,7 @@ class ClaudeAuthError(Exception): def check_claude_auth(timeout: int = 10) -> None: """Check that claude CLI is authenticated before running a pipeline. - Runs: claude -p 'ok' --output-format json --no-verbose with timeout. + Runs: claude -p 'ok' --output-format json with timeout. Returns None if auth is confirmed. Raises ClaudeAuthError if: - claude CLI not found in PATH (FileNotFoundError) @@ -89,7 +89,7 @@ def check_claude_auth(timeout: int = 10) -> None: env = _build_claude_env() try: proc = subprocess.run( - [claude_cmd, "-p", "ok", "--output-format", "json", "--no-verbose"], + [claude_cmd, "-p", "ok", "--output-format", "json"], capture_output=True, text=True, timeout=timeout, @@ -1084,7 +1084,13 @@ def run_pipeline( last_role = steps[-1].get("role", "") if steps else "" auto_eligible = last_role in {"tester", "reviewer"} - if mode == "auto_complete" and auto_eligible: + # Guard: re-fetch current status — user may have manually changed it while pipeline ran + current_task = models.get_task(conn, task_id) + current_status = current_task.get("status") if current_task else None + + if current_status in ("done", "cancelled"): + pass # User finished manually — don't overwrite + elif mode == "auto_complete" and auto_eligible: # Auto-complete mode: last step is tester/reviewer — skip review, approve immediately models.update_task(conn, task_id, status="done") try: @@ -1114,8 +1120,8 @@ def run_pipeline( except Exception: pass else: - # Review mode: wait for manual approval - models.update_task(conn, task_id, status="review", execution_mode="review") + # Review mode: wait for manual approval (don't overwrite execution_mode) + models.update_task(conn, task_id, status="review") # Run post-pipeline hooks (failures don't affect pipeline status) try: diff --git a/tests/test_runner.py b/tests/test_runner.py index 06530f6..ad6e5eb 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -394,6 +394,120 @@ class TestAutoMode: mock_resolve.assert_called_once_with(conn, "VDOL-001", pending) +# --------------------------------------------------------------------------- +# KIN-080: Guard — не перезаписывать статус, если пользователь изменил вручную +# --------------------------------------------------------------------------- + +class TestPipelineStatusGuard: + """Тесты guard-check: pipeline не должен перезаписывать статус задачи, + если пользователь вручную изменил его на 'done' или 'cancelled' пока + pipeline выполнялся.""" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("agents.runner._get_changed_files") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_pipeline_preserves_done_status_set_during_execution( + self, mock_run, mock_hooks, mock_get_files, mock_learn, mock_autocommit, conn + ): + """Guard: если пользователь вручную поставил 'done' пока pipeline работал — + итоговый статус должен остаться 'done', а не перезаписаться в 'review'.""" + def side_effect(*args, **kwargs): + # Имитируем ручную смену статуса во время выполнения агента + models.update_task(conn, "VDOL-001", status="done") + return _mock_claude_success({"result": "done"}) + + mock_run.side_effect = side_effect + mock_hooks.return_value = [] + mock_get_files.return_value = [] + mock_learn.return_value = {"added": 0, "skipped": 0} + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "done" # guard НЕ перезаписал в "review" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("agents.runner._get_changed_files") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_pipeline_preserves_cancelled_status_set_during_execution( + self, mock_run, mock_hooks, mock_get_files, mock_learn, mock_autocommit, conn + ): + """Guard: если пользователь вручную поставил 'cancelled' пока pipeline работал — + итоговый статус должен остаться 'cancelled'.""" + def side_effect(*args, **kwargs): + models.update_task(conn, "VDOL-001", status="cancelled") + return _mock_claude_success({"result": "done"}) + + mock_run.side_effect = side_effect + mock_hooks.return_value = [] + mock_get_files.return_value = [] + mock_learn.return_value = {"added": 0, "skipped": 0} + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "cancelled" # guard НЕ перезаписал в "review" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("agents.runner._get_changed_files") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_pipeline_sets_review_when_no_manual_override( + self, mock_run, mock_hooks, mock_get_files, mock_learn, mock_autocommit, conn + ): + """Нормальный случай: задача в in_progress, пользователь не трогал статус — + после pipeline устанавливается 'review'.""" + mock_run.return_value = _mock_claude_success({"result": "done"}) + mock_hooks.return_value = [] + mock_get_files.return_value = [] + mock_learn.return_value = {"added": 0, "skipped": 0} + + steps = [{"role": "debugger", "brief": "find"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "review" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_learning_extraction") + @patch("agents.runner._get_changed_files") + @patch("core.followup.generate_followups") + @patch("agents.runner.run_hooks") + @patch("agents.runner.subprocess.run") + def test_auto_mode_preserves_done_status_set_during_execution( + self, mock_run, mock_hooks, mock_followup, mock_get_files, mock_learn, mock_autocommit, conn + ): + """Guard в auto_complete mode: если пользователь вручную поставил 'done' + пока pipeline работал — guard пропускает обновление (уже done).""" + def side_effect(*args, **kwargs): + models.update_task(conn, "VDOL-001", status="done") + return _mock_claude_success({"result": "done"}) + + mock_run.side_effect = side_effect + mock_hooks.return_value = [] + mock_followup.return_value = {"created": [], "pending_actions": []} + mock_get_files.return_value = [] + mock_learn.return_value = {"added": 0, "skipped": 0} + + models.update_project(conn, "vdol", execution_mode="auto_complete") + steps = [{"role": "tester", "brief": "verify"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "done" + + # --------------------------------------------------------------------------- # Retry on permission error # ---------------------------------------------------------------------------