kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).

This commit is contained in:
Gros Frumos 2026-03-16 09:13:34 +02:00
parent a605e9d110
commit d9172fc17c
35 changed files with 2375 additions and 23 deletions

View file

@ -9,6 +9,7 @@ from core import models
from agents.runner import (
run_agent, run_pipeline, run_audit, _try_parse_json, _run_learning_extraction,
_build_claude_env, _resolve_claude_cmd, _EXTRA_PATH_DIRS, _run_autocommit,
_parse_agent_blocked,
)
@ -1694,3 +1695,224 @@ class TestAuditLogDangerousSkip:
"SELECT * FROM audit_log WHERE task_id='VDOL-001'"
).fetchall()
assert len(rows) == 0
# ---------------------------------------------------------------------------
# KIN-016: Blocked Protocol
# ---------------------------------------------------------------------------
class TestParseAgentBlocked:
def test_returns_none_on_failure(self):
result = {"success": False, "output": {"status": "blocked", "reason": "no access"}}
assert _parse_agent_blocked(result) is None
def test_returns_none_when_output_not_dict(self):
result = {"success": True, "output": "plain text output"}
assert _parse_agent_blocked(result) is None
def test_returns_none_when_status_not_blocked(self):
result = {"success": True, "output": {"status": "done", "changes": []}}
assert _parse_agent_blocked(result) is None
def test_detects_status_blocked(self):
result = {"success": True, "output": {"status": "blocked", "reason": "no file access"}}
blocked = _parse_agent_blocked(result)
assert blocked is not None
assert blocked["reason"] == "no file access"
assert blocked["blocked_at"] is not None
def test_detects_verdict_blocked(self):
"""reviewer.md uses verdict: blocked instead of status: blocked."""
result = {"success": True, "output": {"verdict": "blocked", "blocked_reason": "unreadable"}}
blocked = _parse_agent_blocked(result)
assert blocked is not None
assert blocked["reason"] == "unreadable"
def test_uses_provided_blocked_at(self):
result = {"success": True, "output": {
"status": "blocked", "reason": "out of scope",
"blocked_at": "2026-03-16T10:00:00",
}}
blocked = _parse_agent_blocked(result)
assert blocked["blocked_at"] == "2026-03-16T10:00:00"
def test_falls_back_blocked_at_if_missing(self):
result = {"success": True, "output": {"status": "blocked", "reason": "x"}}
blocked = _parse_agent_blocked(result)
assert "T" in blocked["blocked_at"] # ISO-8601 with T separator
def test_does_not_check_nested_status(self):
"""Nested status='blocked' in sub-fields must NOT trigger blocked protocol."""
result = {"success": True, "output": {
"status": "done",
"changes": [{"file": "a.py", "status": "blocked"}], # nested — must be ignored
}}
assert _parse_agent_blocked(result) is None
class TestPipelineBlockedProtocol:
@patch("agents.runner._run_autocommit")
@patch("agents.runner.subprocess.run")
def test_pipeline_stops_on_semantic_blocked(self, mock_run, mock_autocommit, conn):
"""KIN-016: когда агент возвращает status='blocked', пайплайн останавливается."""
# First step returns semantic blocked
mock_run.return_value = _mock_claude_success({
"result": json.dumps({"status": "blocked", "reason": "cannot access external API"}),
})
steps = [
{"role": "debugger", "brief": "find bug"},
{"role": "tester", "brief": "verify"},
]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert result["steps_completed"] == 0
assert "blocked" in result["error"]
assert result["blocked_by"] == "debugger"
assert result["blocked_reason"] == "cannot access external API"
# Task marked as blocked with enriched fields
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
assert task["blocked_reason"] == "cannot access external API"
assert task["blocked_agent_role"] == "debugger"
assert task["blocked_pipeline_step"] == "1"
assert task["blocked_at"] is not None
# Pipeline marked as failed
pipe = conn.execute("SELECT * FROM pipelines WHERE task_id='VDOL-001'").fetchone()
assert pipe["status"] == "failed"
@patch("agents.runner._run_autocommit")
@patch("agents.runner.subprocess.run")
def test_pipeline_blocks_on_second_step(self, mock_run, mock_autocommit, conn):
"""KIN-016: blocked на шаге 2 → steps_completed=1, pipeline_step='2'."""
mock_run.side_effect = [
_mock_claude_success({"result": json.dumps({"status": "done", "changes": []})}),
_mock_claude_success({"result": json.dumps({
"status": "blocked", "reason": "test environment unavailable",
})}),
]
steps = [
{"role": "backend_dev", "brief": "implement"},
{"role": "tester", "brief": "test"},
]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert result["steps_completed"] == 1
assert result["blocked_by"] == "tester"
task = models.get_task(conn, "VDOL-001")
assert task["blocked_agent_role"] == "tester"
assert task["blocked_pipeline_step"] == "2"
@patch("agents.runner._run_autocommit")
@patch("agents.runner.subprocess.run")
def test_reviewer_verdict_blocked_stops_pipeline(self, mock_run, mock_autocommit, conn):
"""KIN-016: reviewer возвращает verdict='blocked' → пайплайн останавливается."""
mock_run.return_value = _mock_claude_success({
"result": json.dumps({
"verdict": "blocked", "status": "blocked",
"reason": "cannot read implementation files",
}),
})
steps = [{"role": "reviewer", "brief": "review"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
assert result["blocked_by"] == "reviewer"
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
assert task["blocked_agent_role"] == "reviewer"
# ---------------------------------------------------------------------------
# KIN-071: _save_sysadmin_output
# ---------------------------------------------------------------------------
class TestSaveSysadminOutput:
"""KIN-071: _save_sysadmin_output парсит и сохраняет decisions + modules."""
@pytest.fixture
def ops_conn(self):
c = init_db(":memory:")
models.create_project(
c, "srv", "Server", "",
project_type="operations",
ssh_host="10.0.0.1",
)
models.create_task(c, "SRV-001", "srv", "Scan server")
yield c
c.close()
def test_saves_decisions_and_modules(self, ops_conn):
"""KIN-071: sysadmin output корректно сохраняет decisions и modules."""
from agents.runner import _save_sysadmin_output
output = {
"status": "done",
"decisions": [
{"type": "gotcha", "title": "Port 8080 open", "description": "nginx on 8080", "tags": ["server"]},
{"type": "decision", "title": "Docker used", "description": "docker 24.0", "tags": ["docker"]},
],
"modules": [
{"name": "nginx", "type": "service", "path": "/etc/nginx", "description": "web proxy"},
],
}
result = _save_sysadmin_output(
ops_conn, "srv", "SRV-001",
{"raw_output": json.dumps(output)}
)
assert result["decisions_added"] == 2
assert result["modules_added"] == 1
decisions = models.get_decisions(ops_conn, "srv")
assert len(decisions) == 2
modules = models.get_modules(ops_conn, "srv")
assert len(modules) == 1
assert modules[0]["name"] == "nginx"
def test_idempotent_on_duplicate_decisions(self, ops_conn):
"""KIN-071: повторный вызов не создаёт дублей."""
from agents.runner import _save_sysadmin_output
output = {
"decisions": [
{"type": "gotcha", "title": "Port 8080 open", "description": "nginx on 8080"},
],
"modules": [],
}
r1 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
r2 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert r1["decisions_added"] == 1
assert r2["decisions_added"] == 0 # deduped
assert r2["decisions_skipped"] == 1
def test_idempotent_on_duplicate_modules(self, ops_conn):
"""KIN-071: повторный вызов не создаёт дублей модулей."""
from agents.runner import _save_sysadmin_output
output = {
"decisions": [],
"modules": [{"name": "nginx", "type": "service", "path": "/etc/nginx"}],
}
r1 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
r2 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert r1["modules_added"] == 1
assert r2["modules_skipped"] == 1
assert len(models.get_modules(ops_conn, "srv")) == 1
def test_handles_non_json_output(self, ops_conn):
"""KIN-071: не-JSON вывод не вызывает исключения."""
from agents.runner import _save_sysadmin_output
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": "not json"})
assert result["decisions_added"] == 0
assert result["modules_added"] == 0
def test_handles_empty_output(self, ops_conn):
"""KIN-071: пустой вывод не вызывает исключения."""
from agents.runner import _save_sysadmin_output
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": ""})
assert result["decisions_added"] == 0