Add permission-aware follow-up flow with interactive resolution
When follow-up agent detects permission-blocked items ("ручное
применение", "permission denied", etc.), they become pending_actions
instead of auto-created tasks. User chooses per item:
1. Rerun with --dangerously-skip-permissions
2. Create manual task
3. Skip
core/followup.py:
_is_permission_blocked() — regex detection of 9 permission patterns
generate_followups() returns {created, pending_actions}
resolve_pending_action() — handles rerun/manual_task/skip
agents/runner.py:
_run_claude(allow_write=True) adds --dangerously-skip-permissions
run_agent/run_pipeline pass allow_write through
CLI: kin approve --followup — interactive 1/2/3 prompt per blocked item
API: POST /approve returns {needs_decision, pending_actions}
POST /resolve resolves individual actions
Frontend: pending actions shown as cards with 3 buttons in approve modal
136 tests, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9264415776
commit
ab693d3c4d
7 changed files with 356 additions and 73 deletions
149
core/followup.py
149
core/followup.py
|
|
@ -1,14 +1,34 @@
|
|||
"""
|
||||
Kin follow-up generator — analyzes pipeline output and creates follow-up tasks.
|
||||
Runs a PM agent to parse results and produce actionable task list.
|
||||
Detects permission-blocked items and returns them as pending actions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from core import models
|
||||
from core.context_builder import format_prompt, PROMPTS_DIR
|
||||
|
||||
_PERMISSION_PATTERNS = [
|
||||
r"(?i)permission\s+denied",
|
||||
r"(?i)ручное\s+применение",
|
||||
r"(?i)не\s+получил[иа]?\s+разрешени[ея]",
|
||||
r"(?i)cannot\s+write",
|
||||
r"(?i)read[- ]?only",
|
||||
r"(?i)нет\s+прав\s+на\s+запись",
|
||||
r"(?i)manually\s+appl",
|
||||
r"(?i)apply\s+manually",
|
||||
r"(?i)требуется\s+ручн",
|
||||
]
|
||||
|
||||
|
||||
def _is_permission_blocked(item: dict) -> bool:
|
||||
"""Check if a follow-up item describes a permission/write failure."""
|
||||
text = f"{item.get('title', '')} {item.get('brief', '')}".lower()
|
||||
return any(re.search(p, text) for p in _PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
"""Collect all pipeline step outputs for a task into a single string."""
|
||||
|
|
@ -48,27 +68,35 @@ def generate_followups(
|
|||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
dry_run: bool = False,
|
||||
) -> list[dict]:
|
||||
) -> dict:
|
||||
"""Analyze pipeline output and create follow-up tasks.
|
||||
|
||||
1. Collects all agent_logs output for the task
|
||||
2. Runs followup agent (claude -p) to analyze and propose tasks
|
||||
3. Creates tasks in DB with parent_task_id = task_id
|
||||
Returns dict:
|
||||
{
|
||||
"created": [task, ...], # tasks created immediately
|
||||
"pending_actions": [action, ...], # items needing user decision
|
||||
}
|
||||
|
||||
Returns list of created task dicts.
|
||||
A pending_action looks like:
|
||||
{
|
||||
"type": "permission_fix",
|
||||
"description": "...",
|
||||
"original_item": {...}, # raw item from PM
|
||||
"options": ["rerun", "manual_task", "skip"],
|
||||
}
|
||||
"""
|
||||
task = models.get_task(conn, task_id)
|
||||
if not task:
|
||||
return []
|
||||
return {"created": [], "pending_actions": []}
|
||||
|
||||
project_id = task["project_id"]
|
||||
project = models.get_project(conn, project_id)
|
||||
if not project:
|
||||
return []
|
||||
return {"created": [], "pending_actions": []}
|
||||
|
||||
pipeline_output = _collect_pipeline_output(conn, task_id)
|
||||
if not pipeline_output:
|
||||
return []
|
||||
return {"created": [], "pending_actions": []}
|
||||
|
||||
# Build context for followup agent
|
||||
language = project.get("language", "ru")
|
||||
|
|
@ -94,7 +122,7 @@ def generate_followups(
|
|||
prompt = format_prompt(context, "followup")
|
||||
|
||||
if dry_run:
|
||||
return [{"_dry_run": True, "_prompt": prompt}]
|
||||
return {"created": [{"_dry_run": True, "_prompt": prompt}], "pending_actions": []}
|
||||
|
||||
# Run followup agent
|
||||
from agents.runner import _run_claude, _try_parse_json
|
||||
|
|
@ -105,43 +133,100 @@ def generate_followups(
|
|||
# Parse the task list from output
|
||||
parsed = _try_parse_json(output)
|
||||
if not isinstance(parsed, list):
|
||||
# Maybe it's wrapped in a dict
|
||||
if isinstance(parsed, dict):
|
||||
parsed = parsed.get("tasks") or parsed.get("followups") or []
|
||||
else:
|
||||
return []
|
||||
return {"created": [], "pending_actions": []}
|
||||
|
||||
# Create tasks in DB
|
||||
# Separate permission-blocked items from normal ones
|
||||
created = []
|
||||
pending_actions = []
|
||||
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict) or "title" not in item:
|
||||
continue
|
||||
new_id = _next_task_id(conn, project_id)
|
||||
brief = item.get("brief")
|
||||
brief_dict = {"source": f"followup:{task_id}"}
|
||||
if item.get("type"):
|
||||
brief_dict["route_type"] = item["type"]
|
||||
if brief:
|
||||
brief_dict["description"] = brief
|
||||
|
||||
t = models.create_task(
|
||||
conn, new_id, project_id,
|
||||
title=item["title"],
|
||||
priority=item.get("priority", 5),
|
||||
parent_task_id=task_id,
|
||||
brief=brief_dict,
|
||||
)
|
||||
created.append(t)
|
||||
if _is_permission_blocked(item):
|
||||
pending_actions.append({
|
||||
"type": "permission_fix",
|
||||
"description": item["title"],
|
||||
"original_item": item,
|
||||
"options": ["rerun", "manual_task", "skip"],
|
||||
})
|
||||
else:
|
||||
new_id = _next_task_id(conn, project_id)
|
||||
brief_dict = {"source": f"followup:{task_id}"}
|
||||
if item.get("type"):
|
||||
brief_dict["route_type"] = item["type"]
|
||||
if item.get("brief"):
|
||||
brief_dict["description"] = item["brief"]
|
||||
|
||||
t = models.create_task(
|
||||
conn, new_id, project_id,
|
||||
title=item["title"],
|
||||
priority=item.get("priority", 5),
|
||||
parent_task_id=task_id,
|
||||
brief=brief_dict,
|
||||
)
|
||||
created.append(t)
|
||||
|
||||
# Log the followup generation
|
||||
models.log_agent_run(
|
||||
conn, project_id, "followup_pm", "generate_followups",
|
||||
task_id=task_id,
|
||||
output_summary=json.dumps(
|
||||
[{"id": t["id"], "title": t["title"]} for t in created],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
output_summary=json.dumps({
|
||||
"created": [{"id": t["id"], "title": t["title"]} for t in created],
|
||||
"pending": len(pending_actions),
|
||||
}, ensure_ascii=False),
|
||||
success=True,
|
||||
)
|
||||
|
||||
return created
|
||||
return {"created": created, "pending_actions": pending_actions}
|
||||
|
||||
|
||||
def resolve_pending_action(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
action: dict,
|
||||
choice: str,
|
||||
) -> dict | None:
|
||||
"""Resolve a single pending action.
|
||||
|
||||
choice: "rerun" | "manual_task" | "skip"
|
||||
Returns created task dict for "manual_task", None otherwise.
|
||||
"""
|
||||
task = models.get_task(conn, task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
project_id = task["project_id"]
|
||||
item = action.get("original_item", {})
|
||||
|
||||
if choice == "skip":
|
||||
return None
|
||||
|
||||
if choice == "manual_task":
|
||||
new_id = _next_task_id(conn, project_id)
|
||||
brief_dict = {"source": f"followup:{task_id}"}
|
||||
if item.get("type"):
|
||||
brief_dict["route_type"] = item["type"]
|
||||
if item.get("brief"):
|
||||
brief_dict["description"] = item["brief"]
|
||||
return models.create_task(
|
||||
conn, new_id, project_id,
|
||||
title=item.get("title", "Manual fix required"),
|
||||
priority=item.get("priority", 5),
|
||||
parent_task_id=task_id,
|
||||
brief=brief_dict,
|
||||
)
|
||||
|
||||
if choice == "rerun":
|
||||
# Re-run pipeline for the parent task with allow_write
|
||||
from agents.runner import run_pipeline
|
||||
steps = [{"role": item.get("type", "frontend_dev"),
|
||||
"brief": item.get("brief", item.get("title", "")),
|
||||
"model": "sonnet"}]
|
||||
result = run_pipeline(conn, task_id, steps, allow_write=True)
|
||||
return {"rerun_result": result}
|
||||
|
||||
return None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue