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:
parent
01b269e2b8
commit
3cb516193b
4 changed files with 256 additions and 25 deletions
122
agents/runner.py
122
agents/runner.py
|
|
@ -11,6 +11,8 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
from core import models
|
||||
from core.context_builder import build_context, format_prompt
|
||||
from core.hooks import run_hooks
|
||||
|
|
@ -358,6 +360,21 @@ def run_audit(
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission error detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_permission_error(result: dict) -> bool:
|
||||
"""Return True if agent result indicates a permission/write failure."""
|
||||
from core.followup import PERMISSION_PATTERNS
|
||||
output = (result.get("raw_output") or result.get("output") or "")
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
error = result.get("error_message") or ""
|
||||
text = output + " " + error
|
||||
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -390,6 +407,9 @@ def run_pipeline(
|
|||
if task.get("brief") and isinstance(task["brief"], dict):
|
||||
route_type = task["brief"].get("route_type", "custom") or "custom"
|
||||
|
||||
# Determine execution mode (auto vs review)
|
||||
mode = models.get_effective_mode(conn, project_id, task_id)
|
||||
|
||||
# Create pipeline in DB
|
||||
pipeline = None
|
||||
if not dry_run:
|
||||
|
|
@ -418,9 +438,9 @@ def run_pipeline(
|
|||
allow_write=allow_write,
|
||||
noninteractive=noninteractive,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
if dry_run:
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
# Accumulate stats
|
||||
|
|
@ -429,26 +449,55 @@ def run_pipeline(
|
|||
total_duration += result.get("duration_seconds") or 0
|
||||
|
||||
if not result["success"]:
|
||||
# Pipeline failed — stop and mark as failed
|
||||
if pipeline:
|
||||
models.update_pipeline(
|
||||
conn, pipeline["id"],
|
||||
status="failed",
|
||||
total_cost_usd=total_cost,
|
||||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
# Auto mode: retry once with allow_write on permission error
|
||||
if mode == "auto" and not allow_write and _is_permission_error(result):
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="task_permission_retry",
|
||||
task_modules=task_modules)
|
||||
except Exception:
|
||||
pass
|
||||
retry = run_agent(
|
||||
conn, role, task_id, project_id,
|
||||
model=model,
|
||||
previous_output=previous_output,
|
||||
brief_override=brief,
|
||||
dry_run=False,
|
||||
allow_write=True,
|
||||
noninteractive=noninteractive,
|
||||
)
|
||||
models.update_task(conn, task_id, status="blocked")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Step {i+1}/{len(steps)} ({role}) failed",
|
||||
"steps_completed": i,
|
||||
"results": results,
|
||||
"total_cost_usd": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
"total_duration_seconds": total_duration,
|
||||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
}
|
||||
allow_write = True # subsequent steps also with allow_write
|
||||
total_cost += retry.get("cost_usd") or 0
|
||||
total_tokens += retry.get("tokens_used") or 0
|
||||
total_duration += retry.get("duration_seconds") or 0
|
||||
if retry["success"]:
|
||||
result = retry
|
||||
|
||||
if not result["success"]:
|
||||
# Still failed — block regardless of mode
|
||||
results.append(result)
|
||||
if pipeline:
|
||||
models.update_pipeline(
|
||||
conn, pipeline["id"],
|
||||
status="failed",
|
||||
total_cost_usd=total_cost,
|
||||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
models.update_task(conn, task_id, status="blocked")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Step {i+1}/{len(steps)} ({role}) failed",
|
||||
"steps_completed": i,
|
||||
"results": results,
|
||||
"total_cost_usd": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
"total_duration_seconds": total_duration,
|
||||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Chain output to next step
|
||||
previous_output = result.get("raw_output") or result.get("output")
|
||||
|
|
@ -464,10 +513,38 @@ def run_pipeline(
|
|||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
models.update_task(conn, task_id, status="review")
|
||||
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
|
||||
if mode == "auto":
|
||||
# Auto mode: skip review, approve immediately
|
||||
models.update_task(conn, task_id, status="done")
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="task_auto_approved", task_modules=task_modules)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto followup: generate tasks, auto-resolve permission issues.
|
||||
# Guard: skip for followup-sourced tasks to prevent infinite recursion.
|
||||
task_brief = task.get("brief") or {}
|
||||
is_followup_task = (
|
||||
isinstance(task_brief, dict)
|
||||
and str(task_brief.get("source", "")).startswith("followup:")
|
||||
)
|
||||
if not is_followup_task:
|
||||
try:
|
||||
from core.followup import generate_followups, auto_resolve_pending_actions
|
||||
fu_result = generate_followups(conn, task_id)
|
||||
if fu_result.get("pending_actions"):
|
||||
auto_resolve_pending_actions(conn, task_id, fu_result["pending_actions"])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Review mode: wait for manual approval
|
||||
models.update_task(conn, task_id, status="review")
|
||||
|
||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
event="pipeline_completed", task_modules=task_modules)
|
||||
|
|
@ -483,4 +560,5 @@ def run_pipeline(
|
|||
"total_duration_seconds": total_duration,
|
||||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
"dry_run": dry_run,
|
||||
"mode": mode,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue