kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue