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

@ -1132,4 +1132,136 @@ def test_sync_obsidian_after_patch_returns_sync_result_fields(client, tmp_path):
assert r.status_code == 200
data = r.json()
assert "exported_decisions" in data
assert "tasks_updated" in data
# ---------------------------------------------------------------------------
# KIN-016 — GET /api/notifications — эскалации от заблокированных агентов
# ---------------------------------------------------------------------------
def test_kin016_notifications_empty_when_no_blocked_tasks(client):
"""KIN-016: GET /api/notifications возвращает [] когда нет заблокированных задач."""
r = client.get("/api/notifications")
assert r.status_code == 200
assert r.json() == []
def test_kin016_notifications_returns_blocked_task_as_escalation(client):
"""KIN-016: заблокированная задача появляется в /api/notifications с корректными полями."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(
conn, "P1-001",
status="blocked",
blocked_reason="cannot access external API",
blocked_at="2026-03-16T10:00:00",
blocked_agent_role="debugger",
blocked_pipeline_step="1",
)
conn.close()
r = client.get("/api/notifications")
assert r.status_code == 200
items = r.json()
assert len(items) == 1
item = items[0]
assert item["task_id"] == "P1-001"
assert item["agent_role"] == "debugger"
assert item["reason"] == "cannot access external API"
assert item["pipeline_step"] == "1"
assert item["blocked_at"] == "2026-03-16T10:00:00"
def test_kin016_notifications_contains_project_id_and_title(client):
"""KIN-016: уведомление содержит project_id и title задачи."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="out of scope",
blocked_agent_role="architect")
conn.close()
r = client.get("/api/notifications")
assert r.status_code == 200
item = r.json()[0]
assert item["project_id"] == "p1"
assert item["title"] == "Fix bug"
def test_kin016_notifications_filters_by_project_id(client):
"""KIN-016: ?project_id= фильтрует уведомления по проекту."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
# Создаём второй проект с заблокированной задачей
models.create_project(conn, "p2", "P2", "/p2")
models.create_task(conn, "P2-001", "p2", "Another task")
models.update_task(conn, "P1-001", status="blocked",
blocked_reason="reason A", blocked_agent_role="debugger")
models.update_task(conn, "P2-001", status="blocked",
blocked_reason="reason B", blocked_agent_role="tester")
conn.close()
r = client.get("/api/notifications?project_id=p1")
assert r.status_code == 200
items = r.json()
assert all(i["project_id"] == "p1" for i in items)
assert len(items) == 1
assert items[0]["task_id"] == "P1-001"
def test_kin016_notifications_only_returns_blocked_status(client):
"""KIN-016: задачи в статусе pending/review/done НЕ попадают в уведомления."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
# Задача остаётся в pending (дефолт)
assert models.get_task(conn, "P1-001")["status"] == "pending"
conn.close()
r = client.get("/api/notifications")
assert r.status_code == 200
assert r.json() == []
def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client):
"""KIN-016: после blocked пайплайна задача блокируется, /api/notifications показывает её.
Интеграционный тест: pipeline blocked /api/notifications содержит task.
"""
import json
from unittest.mock import patch, MagicMock
blocked_output = json.dumps({
"result": json.dumps({"status": "blocked", "reason": "no repo access"}),
})
mock_proc = MagicMock()
mock_proc.pid = 123
with patch("web.api.subprocess.Popen") as mock_popen:
mock_popen.return_value = mock_proc
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 202
# Вручную помечаем задачу blocked (имитируем результат пайплайна)
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(
conn, "P1-001",
status="blocked",
blocked_reason="no repo access",
blocked_agent_role="debugger",
blocked_pipeline_step="1",
)
conn.close()
r = client.get("/api/notifications")
assert r.status_code == 200
items = r.json()
assert len(items) == 1
assert items[0]["task_id"] == "P1-001"
assert items[0]["reason"] == "no repo access"
assert items[0]["agent_role"] == "debugger"

View file

@ -265,3 +265,83 @@ class TestReviseContext:
prompt = format_prompt(ctx, "backend_dev", "You are a developer.")
assert "## Director's revision request:" not in prompt
assert "## Your previous output (before revision):" not in prompt
# ---------------------------------------------------------------------------
# KIN-071: project_type and SSH context
# ---------------------------------------------------------------------------
class TestOperationsProject:
"""KIN-071: operations project_type propagates to context and prompt."""
@pytest.fixture
def ops_conn(self):
c = init_db(":memory:")
models.create_project(
c, "srv", "My Server", "",
project_type="operations",
ssh_host="10.0.0.1",
ssh_user="root",
ssh_key_path="~/.ssh/id_rsa",
ssh_proxy_jump="jumpt",
)
models.create_task(c, "SRV-001", "srv", "Scan server")
yield c
c.close()
def test_slim_project_includes_project_type(self, ops_conn):
"""KIN-071: _slim_project включает project_type."""
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
assert ctx["project"]["project_type"] == "operations"
def test_slim_project_includes_ssh_fields_for_operations(self, ops_conn):
"""KIN-071: _slim_project включает ssh_* поля для operations-проектов."""
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
proj = ctx["project"]
assert proj["ssh_host"] == "10.0.0.1"
assert proj["ssh_user"] == "root"
assert proj["ssh_key_path"] == "~/.ssh/id_rsa"
assert proj["ssh_proxy_jump"] == "jumpt"
def test_slim_project_no_ssh_fields_for_development(self):
"""KIN-071: development-проект не получает ssh_* в slim."""
c = init_db(":memory:")
models.create_project(c, "dev", "Dev", "/path")
models.create_task(c, "DEV-001", "dev", "A task")
ctx = build_context(c, "DEV-001", "backend_dev", "dev")
assert "ssh_host" not in ctx["project"]
c.close()
def test_sysadmin_context_gets_decisions_and_modules(self, ops_conn):
"""KIN-071: sysadmin роль получает все decisions и modules."""
models.add_module(ops_conn, "srv", "nginx", "service", "/etc/nginx")
models.add_decision(ops_conn, "srv", "gotcha", "Port 80 in use", "conflict")
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
assert "decisions" in ctx
assert "modules" in ctx
assert len(ctx["modules"]) == 1
def test_format_prompt_includes_ssh_connection_section(self, ops_conn):
"""KIN-071: format_prompt добавляет '## SSH Connection' для operations."""
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.")
assert "## SSH Connection" in prompt
assert "10.0.0.1" in prompt
assert "root" in prompt
assert "jumpt" in prompt
def test_format_prompt_no_ssh_section_for_development(self):
"""KIN-071: development-проект не получает SSH-секцию в prompt."""
c = init_db(":memory:")
models.create_project(c, "dev", "Dev", "/path")
models.create_task(c, "DEV-001", "dev", "A task")
ctx = build_context(c, "DEV-001", "backend_dev", "dev")
prompt = format_prompt(ctx, "backend_dev", "You are a dev.")
assert "## SSH Connection" not in prompt
c.close()
def test_format_prompt_includes_project_type(self, ops_conn):
"""KIN-071: format_prompt включает Project type в секцию проекта."""
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.")
assert "Project type: operations" in prompt

View file

@ -55,6 +55,40 @@ def test_update_project_tech_stack_json(conn):
assert updated["tech_stack"] == ["python", "fastapi"]
# -- project_type and SSH fields (KIN-071) --
def test_create_operations_project(conn):
"""KIN-071: operations project stores SSH fields."""
p = models.create_project(
conn, "srv1", "My Server", "",
project_type="operations",
ssh_host="10.0.0.1",
ssh_user="root",
ssh_key_path="~/.ssh/id_rsa",
ssh_proxy_jump="jumpt",
)
assert p["project_type"] == "operations"
assert p["ssh_host"] == "10.0.0.1"
assert p["ssh_user"] == "root"
assert p["ssh_key_path"] == "~/.ssh/id_rsa"
assert p["ssh_proxy_jump"] == "jumpt"
def test_create_development_project_defaults(conn):
"""KIN-071: development is default project_type."""
p = models.create_project(conn, "devp", "Dev Project", "/path")
assert p["project_type"] == "development"
assert p["ssh_host"] is None
def test_update_project_ssh_fields(conn):
"""KIN-071: update_project can set SSH fields."""
models.create_project(conn, "srv2", "Server 2", "", project_type="operations")
updated = models.update_project(conn, "srv2", ssh_host="192.168.1.1", ssh_user="pelmen")
assert updated["ssh_host"] == "192.168.1.1"
assert updated["ssh_user"] == "pelmen"
# -- validate_completion_mode (KIN-063) --
def test_validate_completion_mode_valid_auto_complete():

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