kin: KIN-136-backend_dev
This commit is contained in:
parent
2f7ccffbc8
commit
aac75dbfdc
4 changed files with 592 additions and 9 deletions
|
|
@ -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")
|
||||
|
|
|
|||
404
tests/test_kin_136_auto_return.py
Normal file
404
tests/test_kin_136_auto_return.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue