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:
johnfrum1234 2026-03-15 15:16:48 +02:00
parent 9264415776
commit ab693d3c4d
7 changed files with 356 additions and 73 deletions

View file

@ -92,6 +92,13 @@ export interface TaskFull extends Task {
related_decisions: Decision[]
}
export interface PendingAction {
type: string
description: string
original_item: Record<string, unknown>
options: string[]
}
export interface CostEntry {
project_id: string
project_name: string
@ -113,7 +120,9 @@ export const api = {
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
post<Task>('/tasks', data),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
post<{ status: string; followup_tasks: Task[] }>(`/tasks/${id}/approve`, data || {}),
post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}),
resolveAction: (id: string, action: PendingAction, choice: string) =>
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
rejectTask: (id: string, reason: string) =>
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
runTask: (id: string) =>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type TaskFull, type PipelineStep } from '../api'
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -18,6 +18,8 @@ const showApprove = ref(false)
const approveForm = ref({ title: '', description: '', type: 'decision', createFollowups: true })
const approveLoading = ref(false)
const followupResults = ref<{ id: string; title: string }[]>([])
const pendingActions = ref<PendingAction[]>([])
const resolvingAction = ref(false)
// Reject modal
const showReject = ref(false)
@ -78,6 +80,7 @@ async function approve() {
if (!task.value) return
approveLoading.value = true
followupResults.value = []
pendingActions.value = []
try {
const data: Record<string, unknown> = {
create_followups: approveForm.value.createFollowups,
@ -90,7 +93,11 @@ async function approve() {
const res = await api.approveTask(props.id, data as any)
if (res.followup_tasks?.length) {
followupResults.value = res.followup_tasks.map(t => ({ id: t.id, title: t.title }))
} else {
}
if (res.pending_actions?.length) {
pendingActions.value = res.pending_actions
}
if (!res.followup_tasks?.length && !res.pending_actions?.length) {
showApprove.value = false
}
approveForm.value = { title: '', description: '', type: 'decision', createFollowups: true }
@ -102,6 +109,25 @@ async function approve() {
}
}
async function resolveAction(action: PendingAction, choice: string) {
resolvingAction.value = true
try {
const res = await api.resolveAction(props.id, action, choice)
// Remove resolved action from list
pendingActions.value = pendingActions.value.filter(a => a !== action)
if (choice === 'manual_task' && res.result && typeof res.result === 'object' && 'id' in res.result) {
followupResults.value.push({ id: (res.result as any).id, title: (res.result as any).title })
}
if (choice === 'rerun') {
await load()
}
} catch (e: any) {
error.value = e.message
} finally {
resolvingAction.value = false
}
}
async function reject() {
if (!task.value || !rejectReason.value) return
try {
@ -242,9 +268,31 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
</div>
<!-- Approve Modal -->
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false; followupResults = []">
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false; followupResults = []; pendingActions = []">
<!-- Pending permission actions -->
<div v-if="pendingActions.length" class="space-y-3">
<p class="text-sm text-yellow-400">Permission issues need your decision:</p>
<div v-for="(action, i) in pendingActions" :key="i"
class="border border-yellow-900/50 rounded-lg p-3 space-y-2">
<p class="text-sm text-gray-300">{{ action.description }}</p>
<div class="flex gap-2">
<button @click="resolveAction(action, 'rerun')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
Rerun (skip permissions)
</button>
<button @click="resolveAction(action, 'manual_task')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700 disabled:opacity-50">
Create task
</button>
<button @click="resolveAction(action, 'skip')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-gray-800 text-gray-500 border border-gray-700 rounded hover:bg-gray-700 disabled:opacity-50">
Skip
</button>
</div>
</div>
</div>
<!-- Follow-up results -->
<div v-if="followupResults.length" class="space-y-3">
<div v-if="followupResults.length && !pendingActions.length" class="space-y-3">
<p class="text-sm text-green-400">Task approved. Created {{ followupResults.length }} follow-up tasks:</p>
<div class="space-y-1">
<router-link v-for="f in followupResults" :key="f.id" :to="`/task/${f.id}`"
@ -258,7 +306,7 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
</button>
</div>
<!-- Approve form -->
<form v-else @submit.prevent="approve" class="space-y-3">
<form v-if="!followupResults.length && !pendingActions.length" @submit.prevent="approve" class="space-y-3">
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" v-model="approveForm.createFollowups"
class="rounded border-gray-600 bg-gray-800 text-blue-500" />