From c67fa379b3fe88fd5b68e52facbf7c222f2f61dd Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 17:30:31 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-080=20=D0=A0=D0=B0=D0=B7=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20=D1=81=20KIN-FIX-003=20?= =?UTF-8?q?=D0=B8=20KIN-FIX-004,=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D1=83=D0=B6=D0=B5=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B0,=20=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B1=D0=B5=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20?= =?UTF-8?q?(=D1=80=D1=83=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B0=D1=8E)=20=D0=BD=D0=BE=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5=20=D0=BD=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B8=D0=B9=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/runner.py | 16 ++++-- tests/test_runner.py | 114 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) 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 # ---------------------------------------------------------------------------