feat(KIN-012): auto followup generation and pending_actions auto-resolution

Auto mode now calls generate_followups() after task_auto_approved hook.
Permission-blocked followup items are auto-resolved: rerun first, fallback
to manual_task on failure. Recursion guard skips followup-sourced tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 19:49:34 +02:00
parent 01b269e2b8
commit 3cb516193b
4 changed files with 256 additions and 25 deletions

View file

@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
from core.db import init_db
from core import models
from core.followup import (
generate_followups, resolve_pending_action,
generate_followups, resolve_pending_action, auto_resolve_pending_actions,
_collect_pipeline_output, _next_task_id, _is_permission_blocked,
)
@ -222,3 +222,48 @@ class TestResolvePendingAction:
def test_nonexistent_task(self, conn):
action = {"type": "permission_fix", "original_item": {}}
assert resolve_pending_action(conn, "NOPE", action, "skip") is None
class TestAutoResolvePendingActions:
@patch("agents.runner._run_claude")
def test_rerun_success_resolves_as_rerun(self, mock_claude, conn):
"""Успешный rerun должен резолвиться как 'rerun'."""
mock_claude.return_value = {
"output": json.dumps({"result": "fixed"}),
"returncode": 0,
}
action = {
"type": "permission_fix",
"description": "Fix X",
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
"options": ["rerun", "manual_task", "skip"],
}
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
assert len(results) == 1
assert results[0]["resolved"] == "rerun"
@patch("agents.runner._run_claude")
def test_rerun_failure_escalates_to_manual_task(self, mock_claude, conn):
"""Провал rerun должен создавать manual_task для эскалации."""
mock_claude.return_value = {"output": "", "returncode": 1}
action = {
"type": "permission_fix",
"description": "Fix X",
"original_item": {"title": "Fix X", "type": "frontend_dev", "brief": "Apply fix"},
"options": ["rerun", "manual_task", "skip"],
}
results = auto_resolve_pending_actions(conn, "VDOL-001", [action])
assert len(results) == 1
assert results[0]["resolved"] == "manual_task"
# Manual task должна быть создана в DB
tasks = models.list_tasks(conn, project_id="vdol")
assert len(tasks) == 2 # VDOL-001 + новая manual task
@patch("agents.runner._run_claude")
def test_empty_pending_actions(self, mock_claude, conn):
"""Пустой список — пустой результат."""
results = auto_resolve_pending_actions(conn, "VDOL-001", [])
assert results == []
mock_claude.assert_not_called()

View file

@ -289,6 +289,87 @@ class TestRunPipeline:
assert result["success"] is True
# ---------------------------------------------------------------------------
# Auto mode
# ---------------------------------------------------------------------------
class TestAutoMode:
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_generates_followups(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode должен вызывать generate_followups после task_auto_approved."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_called_once_with(conn, "VDOL-001")
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "done"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_review_mode_skips_followups(self, mock_run, mock_hooks, mock_followup, conn):
"""Review mode НЕ должен вызывать generate_followups автоматически."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
# Проект остаётся в default "review" mode
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "review"
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_skips_followups_for_followup_tasks(self, mock_run, mock_hooks, mock_followup, conn):
"""Auto mode НЕ должен генерировать followups для followup-задач (предотвращение рекурсии)."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
models.update_project(conn, "vdol", execution_mode="auto")
models.update_task(conn, "VDOL-001", brief={"source": "followup:VDOL-000"})
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_followup.assert_not_called()
@patch("core.followup.auto_resolve_pending_actions")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_resolves_pending_actions(self, mock_run, mock_hooks, mock_followup, mock_resolve, conn):
"""Auto mode должен авто-резолвить pending_actions из followup generation."""
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
pending = [{"type": "permission_fix", "description": "Fix X",
"original_item": {}, "options": ["rerun"]}]
mock_followup.return_value = {"created": [], "pending_actions": pending}
mock_resolve.return_value = [{"resolved": "rerun", "result": {}}]
models.update_project(conn, "vdol", execution_mode="auto")
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_resolve.assert_called_once_with(conn, "VDOL-001", pending)
# ---------------------------------------------------------------------------
# JSON parsing
# ---------------------------------------------------------------------------