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

@ -112,6 +112,44 @@ def list_projects(status: str | None = None):
return summary
class NewProjectCreate(BaseModel):
id: str
name: str
path: str
description: str
roles: list[str]
tech_stack: list[str] | None = None
priority: int = 5
language: str = "ru"
@app.post("/api/projects/new")
def new_project_with_phases(body: NewProjectCreate):
"""Create project + sequential research phases (KIN-059)."""
from core.phases import create_project_with_phases, validate_roles
clean_roles = validate_roles(body.roles)
if not clean_roles:
raise HTTPException(400, "At least one research role must be selected (excluding architect)")
conn = get_conn()
if models.get_project(conn, body.id):
conn.close()
raise HTTPException(409, f"Project '{body.id}' already exists")
try:
result = create_project_with_phases(
conn, body.id, body.name, body.path,
description=body.description,
selected_roles=clean_roles,
tech_stack=body.tech_stack,
priority=body.priority,
language=body.language,
)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@app.get("/api/projects/{project_id}")
def get_project(project_id: str):
conn = get_conn()
@ -126,13 +164,21 @@ def get_project(project_id: str):
return {**p, "tasks": tasks, "modules": mods, "decisions": decisions}
VALID_PROJECT_TYPES = {"development", "operations", "research"}
class ProjectCreate(BaseModel):
id: str
name: str
path: str
path: str = ""
tech_stack: list[str] | None = None
status: str = "active"
priority: int = 5
project_type: str = "development"
ssh_host: str | None = None
ssh_user: str | None = None
ssh_key_path: str | None = None
ssh_proxy_jump: str | None = None
class ProjectPatch(BaseModel):
@ -140,14 +186,28 @@ class ProjectPatch(BaseModel):
autocommit_enabled: bool | None = None
obsidian_vault_path: str | None = None
deploy_command: str | None = None
project_type: str | None = None
ssh_host: str | None = None
ssh_user: str | None = None
ssh_key_path: str | None = None
ssh_proxy_jump: str | None = None
@app.patch("/api/projects/{project_id}")
def patch_project(project_id: str, body: ProjectPatch):
if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None and body.deploy_command is None:
raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, obsidian_vault_path, or deploy_command.")
has_any = any([
body.execution_mode, body.autocommit_enabled is not None,
body.obsidian_vault_path, body.deploy_command is not None,
body.project_type, body.ssh_host is not None,
body.ssh_user is not None, body.ssh_key_path is not None,
body.ssh_proxy_jump is not None,
])
if not has_any:
raise HTTPException(400, "Nothing to update.")
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
if body.project_type is not None and body.project_type not in VALID_PROJECT_TYPES:
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
@ -163,6 +223,16 @@ def patch_project(project_id: str, body: ProjectPatch):
if body.deploy_command is not None:
# Empty string = sentinel for clearing (decision #68)
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
if body.project_type is not None:
fields["project_type"] = body.project_type
if body.ssh_host is not None:
fields["ssh_host"] = body.ssh_host
if body.ssh_user is not None:
fields["ssh_user"] = body.ssh_user
if body.ssh_key_path is not None:
fields["ssh_key_path"] = body.ssh_key_path
if body.ssh_proxy_jump is not None:
fields["ssh_proxy_jump"] = body.ssh_proxy_jump
models.update_project(conn, project_id, **fields)
p = models.get_project(conn, project_id)
conn.close()
@ -229,6 +299,8 @@ def deploy_project(project_id: str):
@app.post("/api/projects")
def create_project(body: ProjectCreate):
if body.project_type not in VALID_PROJECT_TYPES:
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
conn = get_conn()
if models.get_project(conn, body.id):
conn.close()
@ -236,11 +308,105 @@ def create_project(body: ProjectCreate):
p = models.create_project(
conn, body.id, body.name, body.path,
tech_stack=body.tech_stack, status=body.status, priority=body.priority,
project_type=body.project_type,
ssh_host=body.ssh_host,
ssh_user=body.ssh_user,
ssh_key_path=body.ssh_key_path,
ssh_proxy_jump=body.ssh_proxy_jump,
)
conn.close()
return p
# ---------------------------------------------------------------------------
# Phases (KIN-059)
# ---------------------------------------------------------------------------
@app.get("/api/projects/{project_id}/phases")
def get_project_phases(project_id: str):
"""List research phases for a project, with task data joined."""
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
phases = models.list_phases(conn, project_id)
result = []
for phase in phases:
task = models.get_task(conn, phase["task_id"]) if phase.get("task_id") else None
result.append({**phase, "task": task})
conn.close()
return result
class PhaseApprove(BaseModel):
comment: str | None = None
class PhaseReject(BaseModel):
reason: str
class PhaseRevise(BaseModel):
comment: str
@app.post("/api/phases/{phase_id}/approve")
def approve_phase(phase_id: int, body: PhaseApprove | None = None):
"""Approve a research phase and activate the next one."""
from core.phases import approve_phase as _approve
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _approve(conn, phase_id)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@app.post("/api/phases/{phase_id}/reject")
def reject_phase(phase_id: int, body: PhaseReject):
"""Reject a research phase."""
from core.phases import reject_phase as _reject
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _reject(conn, phase_id, body.reason)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@app.post("/api/phases/{phase_id}/revise")
def revise_phase(phase_id: int, body: PhaseRevise):
"""Request revision for a research phase."""
from core.phases import revise_phase as _revise
if not body.comment.strip():
raise HTTPException(400, "comment is required")
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _revise(conn, phase_id, body.comment)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@ -716,6 +882,46 @@ def bootstrap(body: BootstrapRequest):
}
# ---------------------------------------------------------------------------
# Notifications (escalations from blocked agents)
# ---------------------------------------------------------------------------
@app.get("/api/notifications")
def get_notifications(project_id: str | None = None):
"""Return tasks with status='blocked' as escalation notifications.
Each item includes task details, the agent role that blocked it,
the reason, and the pipeline step. Intended for GUI polling (5s interval).
TODO: Telegram send notification on new escalation (telegram_sent: false placeholder).
"""
conn = get_conn()
query = "SELECT * FROM tasks WHERE status = 'blocked'"
params: list = []
if project_id:
query += " AND project_id = ?"
params.append(project_id)
query += " ORDER BY blocked_at DESC, updated_at DESC"
rows = conn.execute(query, params).fetchall()
conn.close()
notifications = []
for row in rows:
t = dict(row)
notifications.append({
"task_id": t["id"],
"project_id": t["project_id"],
"title": t.get("title"),
"agent_role": t.get("blocked_agent_role"),
"reason": t.get("blocked_reason"),
"pipeline_step": t.get("blocked_pipeline_step"),
"blocked_at": t.get("blocked_at") or t.get("updated_at"),
# TODO: Telegram — set to True once notification is sent via Telegram bot
"telegram_sent": False,
})
return notifications
# ---------------------------------------------------------------------------
# SPA static files (AFTER all /api/ routes)
# ---------------------------------------------------------------------------