kin: KIN-136-backend_dev

This commit is contained in:
Gros Frumos 2026-03-21 08:18:11 +02:00
parent 2f7ccffbc8
commit aac75dbfdc
4 changed files with 592 additions and 9 deletions

View file

@ -19,7 +19,7 @@ from core.db import init_db
from core import models
from core.models import RETURN_CATEGORIES
from core.context_builder import build_context
from agents.runner import _save_return_analyst_output, run_pipeline
from agents.runner import _save_return_analyst_output, run_pipeline, _AUTO_RETURN_MAX
# ---------------------------------------------------------------------------
@ -403,7 +403,13 @@ class TestGateCannotCloseRecordsReturn:
def test_gate_rejection_increments_return_count_in_standard_pipeline(
self, mock_run, mock_hooks, mock_followup, conn_autocomplete
):
"""Gate cannot_close in standard pipeline → task.return_count increases by 1."""
"""Gate cannot_close in standard pipeline → return_count increments on each auto-return.
KIN-136: in auto_complete mode, gate rejection triggers auto-return up to _AUTO_RETURN_MAX
times before escalating. Each auto-return increments return_count once; the final
escalation via the human-escalation path also records one more return.
Total: _AUTO_RETURN_MAX + 1 returns (3 auto-returns + 1 final escalation = 4).
"""
conn = conn_autocomplete
mock_run.return_value = _mock_subprocess(
{"verdict": "changes_requested", "reason": "Missing tests"}
@ -415,8 +421,10 @@ class TestGateCannotCloseRecordsReturn:
run_pipeline(conn, "P1-001", steps)
task = models.get_task(conn, "P1-001")
assert task["return_count"] == 1, (
"Gate rejection in standard pipeline should increment return_count"
# KIN-136: _AUTO_RETURN_MAX auto-returns + 1 final escalation recording (git log: 2026-03-21)
assert task["return_count"] == _AUTO_RETURN_MAX + 1, (
"Gate rejection with persistent failure should increment return_count "
f"_AUTO_RETURN_MAX+1 times ({_AUTO_RETURN_MAX + 1})"
)
@patch("core.followup.generate_followups")
@ -447,7 +455,11 @@ class TestGateCannotCloseRecordsReturn:
def test_gate_rejection_records_return_with_recurring_quality_fail_category(
self, mock_run, mock_hooks, mock_followup, conn_autocomplete
):
"""Gate rejection uses 'recurring_quality_fail' as reason_category."""
"""Gate rejection uses 'recurring_quality_fail' as reason_category.
KIN-136: all auto-return records use the same category; final escalation also uses it.
Total returns = _AUTO_RETURN_MAX + 1 (git log: 2026-03-21).
"""
conn = conn_autocomplete
mock_run.return_value = _mock_subprocess(
{"verdict": "changes_requested", "reason": "Need unit tests"}
@ -459,8 +471,10 @@ class TestGateCannotCloseRecordsReturn:
run_pipeline(conn, "P1-001", steps)
returns = models.get_task_returns(conn, "P1-001")
assert len(returns) == 1
assert returns[0]["reason_category"] == "recurring_quality_fail"
assert len(returns) == _AUTO_RETURN_MAX + 1
assert all(r["reason_category"] == "recurring_quality_fail" for r in returns), (
"Все записи возврата должны иметь категорию recurring_quality_fail"
)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")

View file

@ -0,0 +1,404 @@
"""
Tests for KIN-136: Auto-return mechanism.
When a task in auto_complete mode is rejected by a gate agent (reviewer/tester)
WITHOUT an exit_condition, it is automatically re-run with return_analyst prepended
instead of being escalated to a human.
Covers:
1. _parse_exit_condition unit tests valid/invalid/null values, role guard
2. _trigger_auto_return threshold guard return_count >= max should_escalate=True
3. Integration: reviewer changes_requested + no exit_condition auto_returned=True
4. Integration: reviewer changes_requested + exit_condition='login_required' task blocked
5. Integration: escalation pipeline gate rejection no auto-return (guard #1081)
6. Integration: reviewer approved (no exit_condition set) task done (happy path unaffected)
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from core.db import init_db
from core import models
from agents.runner import (
run_pipeline,
_parse_exit_condition,
_trigger_auto_return,
_AUTO_RETURN_MAX,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def conn():
"""Fresh in-memory DB with project and auto_complete task."""
c = init_db(":memory:")
models.create_project(c, "p1", "P1", "/tmp/p1", tech_stack=["python"])
models.create_task(c, "P1-001", "p1", "Implement feature",
brief={"route_type": "feature"})
models.update_task(c, "P1-001", execution_mode="auto_complete")
yield c
c.close()
def _mock_subprocess(agent_output: dict) -> MagicMock:
"""Build subprocess.run mock returning agent_output in claude JSON format."""
m = MagicMock()
m.stdout = json.dumps({"result": json.dumps(agent_output, ensure_ascii=False)})
m.stderr = ""
m.returncode = 0
return m
def _mock_reviewer(verdict: str, reason: str = "Issues found",
exit_condition=None) -> MagicMock:
out = {"verdict": verdict, "reason": reason, "findings": [], "summary": reason}
if exit_condition is not None:
out["exit_condition"] = exit_condition
return _mock_subprocess(out)
def _mock_return_analyst(escalate: bool = False) -> MagicMock:
return _mock_subprocess({
"status": "done",
"escalate_to_dept_head": escalate,
"root_cause_analysis": "quality issue identified",
"refined_brief": "fix the quality issue",
})
# ---------------------------------------------------------------------------
# Unit tests: _parse_exit_condition
# ---------------------------------------------------------------------------
class TestParseExitCondition:
"""KIN-136: exit_condition parsing — fail-open on unknown values."""
def test_valid_login_required(self):
gate_result = {"output": {"verdict": "changes_requested", "exit_condition": "login_required"}}
assert _parse_exit_condition(gate_result, "reviewer") == "login_required"
def test_valid_missing_data(self):
gate_result = {"output": {"verdict": "changes_requested", "exit_condition": "missing_data"}}
assert _parse_exit_condition(gate_result, "reviewer") == "missing_data"
def test_valid_strategic_decision(self):
gate_result = {"output": {"verdict": "changes_requested", "exit_condition": "strategic_decision"}}
assert _parse_exit_condition(gate_result, "reviewer") == "strategic_decision"
def test_null_exit_condition_returns_none(self):
"""null in JSON → None → auto-return."""
gate_result = {"output": {"verdict": "changes_requested", "exit_condition": None}}
assert _parse_exit_condition(gate_result, "reviewer") is None
def test_missing_exit_condition_field_returns_none(self):
"""Field absent → auto-return (fail-open)."""
gate_result = {"output": {"verdict": "changes_requested"}}
assert _parse_exit_condition(gate_result, "reviewer") is None
def test_invalid_exit_condition_returns_none(self):
"""Unknown value → fail-open → None → auto-return (decision #1083 analogy)."""
gate_result = {"output": {"verdict": "changes_requested", "exit_condition": "unknown_value"}}
assert _parse_exit_condition(gate_result, "reviewer") is None
def test_tester_role_always_none(self):
"""Tester does not support exit_condition — always returns None."""
gate_result = {"output": {"status": "failed", "exit_condition": "login_required"}}
assert _parse_exit_condition(gate_result, "tester") is None
def test_non_dict_output_returns_none(self):
"""Non-dict output → fail-open → None."""
gate_result = {"output": "some raw string"}
assert _parse_exit_condition(gate_result, "reviewer") is None
def test_empty_gate_result_returns_none(self):
assert _parse_exit_condition({}, "reviewer") is None
# ---------------------------------------------------------------------------
# Unit tests: _trigger_auto_return threshold guard
# ---------------------------------------------------------------------------
class TestTriggerAutoReturnThreshold:
"""KIN-136: threshold guard — return_count >= max → should_escalate=True."""
def test_threshold_exceeded_returns_should_escalate(self, conn):
"""If return_count is already at max, should_escalate=True without spawning pipeline."""
# Set return_count to max
for _ in range(_AUTO_RETURN_MAX):
models.record_task_return(
conn, task_id="P1-001",
reason_category="recurring_quality_fail",
reason_text="quality issue",
returned_by="reviewer",
)
task = models.get_task(conn, "P1-001")
assert task["return_count"] == _AUTO_RETURN_MAX
result = _trigger_auto_return(
conn, "P1-001", "p1", pipeline=None,
original_steps=[{"role": "reviewer", "model": "opus"}],
gate_role="reviewer",
gate_reason="quality issue",
allow_write=False,
noninteractive=True,
)
assert result["should_escalate"] is True
assert result["reason"] == "auto_return_threshold_exceeded"
def test_below_threshold_returns_should_escalate_false(self, conn):
"""return_count < max → should_escalate=False (auto-return proceeds)."""
# Set return_count to max - 1 (one below threshold)
for _ in range(_AUTO_RETURN_MAX - 1):
models.record_task_return(
conn, task_id="P1-001",
reason_category="recurring_quality_fail",
reason_text="quality issue",
returned_by="reviewer",
)
with patch("agents.runner.run_pipeline") as mock_rp:
mock_rp.return_value = {"success": True, "steps_completed": 2}
result = _trigger_auto_return(
conn, "P1-001", "p1", pipeline=None,
original_steps=[{"role": "reviewer", "model": "opus"}],
gate_role="reviewer",
gate_reason="quality issue",
allow_write=False,
noninteractive=True,
)
assert result["should_escalate"] is False
assert "auto_return_result" in result
def test_threshold_zero_return_count_is_below_max(self, conn):
"""Fresh task with return_count=0 is always below threshold."""
task = models.get_task(conn, "P1-001")
assert (task.get("return_count") or 0) == 0
with patch("agents.runner.run_pipeline") as mock_rp:
mock_rp.return_value = {"success": True, "steps_completed": 1}
result = _trigger_auto_return(
conn, "P1-001", "p1", pipeline=None,
original_steps=[],
gate_role="reviewer",
gate_reason="issue",
allow_write=False,
noninteractive=True,
)
assert result["should_escalate"] is False
# ---------------------------------------------------------------------------
# Integration tests: auto-return end-to-end
# ---------------------------------------------------------------------------
class TestAutoReturnIntegration:
"""KIN-136: end-to-end integration tests for auto-return flow."""
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_reviewer_rejection_without_exit_condition_triggers_auto_return(
self, mock_run, mock_hooks, mock_followup, conn
):
"""reviewer changes_requested + no exit_condition → auto_returned=True in result."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Call sequence:
# 1. reviewer returns "changes_requested" (no exit_condition) — original pipeline
# 2. return_analyst runs — in auto-return sub-pipeline
# 3. reviewer returns "approved" — in auto-return sub-pipeline
mock_run.side_effect = [
_mock_reviewer("changes_requested"), # original reviewer
_mock_return_analyst(escalate=False), # return_analyst in sub-pipeline
_mock_reviewer("approved"), # reviewer in sub-pipeline
]
steps = [{"role": "reviewer", "brief": "review the code"}]
result = run_pipeline(conn, "P1-001", steps)
assert result.get("auto_returned") is True, (
"result должен содержать auto_returned=True при авто-возврате"
)
# Task should be done after the sub-pipeline reviewer approved
task = models.get_task(conn, "P1-001")
assert task["status"] == "done", (
"После авто-возврата и одобрения reviewer — задача должна быть done"
)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_reviewer_rejection_with_exit_condition_blocks_task(
self, mock_run, mock_hooks, mock_followup, conn
):
"""reviewer changes_requested + exit_condition='login_required' → task blocked."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_run.return_value = _mock_reviewer(
"changes_requested",
reason="Need login access to verify",
exit_condition="login_required",
)
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "P1-001", steps)
assert result["success"] is False
assert result.get("auto_returned") is not True, (
"Задача с exit_condition не должна авто-возвращаться"
)
task = models.get_task(conn, "P1-001")
assert task["status"] == "blocked", (
"exit_condition='login_required' → задача должна остаться blocked"
)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_exit_condition_missing_data_blocks_task(
self, mock_run, mock_hooks, mock_followup, conn
):
"""exit_condition='missing_data' → task stays blocked, no auto-return."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_run.return_value = _mock_reviewer(
"changes_requested",
reason="Missing API credentials",
exit_condition="missing_data",
)
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "P1-001", steps)
assert result["success"] is False
task = models.get_task(conn, "P1-001")
assert task["status"] == "blocked"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_return_increments_return_count(
self, mock_run, mock_hooks, mock_followup, conn
):
"""auto-return must increment return_count exactly once."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_run.side_effect = [
_mock_reviewer("changes_requested"), # original reviewer
_mock_return_analyst(escalate=False), # return_analyst
_mock_reviewer("approved"), # reviewer in sub-pipeline
]
steps = [{"role": "reviewer", "brief": "review"}]
run_pipeline(conn, "P1-001", steps)
task = models.get_task(conn, "P1-001")
assert task.get("return_count") == 1, (
"Авто-возврат должен инкрементировать return_count ровно на 1"
)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_return_threshold_exceeded_blocks_task(
self, mock_run, mock_hooks, mock_followup, conn
):
"""When return_count >= _AUTO_RETURN_MAX → task blocked, no more auto-returns."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Pre-fill return_count to max
for _ in range(_AUTO_RETURN_MAX):
models.record_task_return(
conn, task_id="P1-001",
reason_category="recurring_quality_fail",
reason_text="quality issue",
returned_by="reviewer",
)
mock_run.return_value = _mock_reviewer("changes_requested")
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "P1-001", steps)
assert result["success"] is False
assert result.get("auto_returned") is not True
task = models.get_task(conn, "P1-001")
assert task["status"] == "blocked", (
"После достижения порога авто-возвратов задача должна быть blocked"
)
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_escalation_pipeline_gate_rejection_no_auto_return(
self, mock_run, mock_hooks, mock_followup, conn
):
"""Gate rejection in escalation pipeline → task blocked, no auto-return (guard #1081).
Simulates a pipeline with pipeline_type='escalation' by patching create_pipeline
to return a pipeline dict with that type. This is the scenario where _save_return_analyst_output
would run a pipeline it created with escalation type.
"""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_run.return_value = _mock_reviewer("changes_requested")
# Patch create_pipeline so the pipeline created by run_pipeline has type='escalation'
original_create = models.create_pipeline
def create_escalation_pipeline(conn, task_id, project_id, route_type, steps,
parent_pipeline_id=None, department=None):
pipeline = original_create(conn, task_id, project_id, route_type, steps,
parent_pipeline_id=parent_pipeline_id,
department=department)
# Simulate escalation pipeline type
conn.execute(
"UPDATE pipelines SET pipeline_type = 'escalation' WHERE id = ?",
(pipeline["id"],),
)
conn.commit()
pipeline["pipeline_type"] = "escalation"
return pipeline
with patch("agents.runner.models.create_pipeline", side_effect=create_escalation_pipeline):
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "P1-001", steps)
# Guard: escalation pipeline → task must be blocked, NOT auto-returned
assert result.get("auto_returned") is not True, (
"Задача внутри escalation-пайплайна не должна авто-возвращаться"
)
task = models.get_task(conn, "P1-001")
assert task["status"] == "blocked"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_reviewer_approved_task_done_unaffected(
self, mock_run, mock_hooks, mock_followup, conn
):
"""Happy path: reviewer approved → task done, auto-return not triggered."""
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_run.return_value = _mock_reviewer("approved", reason="")
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "P1-001", steps)
assert result["success"] is True
assert result.get("auto_returned") is not True
task = models.get_task(conn, "P1-001")
assert task["status"] == "done"