Add Auto/Review mode toggle and non-interactive runner

- GUI: Auto/Review toggle on TaskDetail and ProjectView
  persisted per-project in localStorage
- Runner: noninteractive param (stdin=DEVNULL, 300s timeout)
  activated by KIN_NONINTERACTIVE=1 env or param
- CLI: --allow-write flag for kin run command
- API: POST /run accepts {allow_write: bool}, sets
  KIN_NONINTERACTIVE=1 and stdin=DEVNULL for subprocess
- Fixes pipeline hanging on interactive claude input (VDOL-002)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 17:35:08 +02:00
parent 03961500e6
commit e755a19633
8 changed files with 174 additions and 18 deletions

View file

@ -276,8 +276,12 @@ def is_task_running(task_id: str):
return {"running": False}
class TaskRun(BaseModel):
allow_write: bool = False
@app.post("/api/tasks/{task_id}/run")
def run_task(task_id: str):
def run_task(task_id: str, body: TaskRun | None = None):
"""Launch pipeline for a task in background. Returns 202."""
conn = get_conn()
t = models.get_task(conn, task_id)
@ -289,12 +293,22 @@ def run_task(task_id: str):
conn.close()
# Launch kin run in background subprocess
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id]
if body and body.allow_write:
cmd.append("--allow-write")
import os
env = os.environ.copy()
env["KIN_NONINTERACTIVE"] = "1"
try:
proc = subprocess.Popen(
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id],
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env,
)
import logging
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")

View file

@ -125,8 +125,8 @@ export const api = {
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) =>
post<{ status: string }>(`/tasks/${id}/run`, {}),
runTask: (id: string, allowWrite = false) =>
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }),
bootstrap: (data: { path: string; id: string; name: string }) =>
post<{ project: Project }>('/bootstrap', data),
}

View file

@ -16,6 +16,18 @@ const taskStatusFilter = ref('')
const decisionTypeFilter = ref('')
const decisionSearch = ref('')
// Auto/Review mode (persisted per project)
const autoMode = ref(false)
function loadMode() {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
}
function toggleMode() {
autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
}
// Add task modal
const showAddTask = ref(false)
const taskForm = ref({ title: '', priority: 5, route_type: '' })
@ -37,7 +49,7 @@ async function load() {
}
}
onMounted(load)
onMounted(() => { load(); loadMode() })
const filteredTasks = computed(() => {
if (!project.value) return []
@ -114,7 +126,7 @@ async function runTask(taskId: string, event: Event) {
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
try {
await api.runTask(taskId)
await api.runTask(taskId, autoMode.value)
await load()
} catch (e: any) {
error.value = e.message
@ -195,10 +207,20 @@ async function addDecision() {
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Task
</button>
<div class="flex gap-2">
<button @click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Auto' : '&#x1F512; Review' }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Task
</button>
</div>
</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1">

View file

@ -25,10 +25,25 @@ const resolvingAction = ref(false)
const showReject = ref(false)
const rejectReason = ref('')
// Auto/Review mode (persisted per project)
const autoMode = ref(false)
function loadMode(projectId: string) {
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto'
}
function toggleMode() {
autoMode.value = !autoMode.value
if (task.value) {
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
}
}
async function load() {
try {
const prev = task.value
task.value = await api.taskFull(props.id)
if (task.value?.project_id) loadMode(task.value.project_id)
// Auto-start polling if task is in_progress
if (task.value.status === 'in_progress' && !polling.value) {
startPolling()
@ -160,7 +175,7 @@ async function reject() {
async function runPipeline() {
try {
await api.runTask(props.id)
await api.runTask(props.id, autoMode.value)
startPolling()
await load()
} catch (e: any) {
@ -270,6 +285,15 @@ const isRunning = computed(() => task.value?.status === 'in_progress')
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
&#10007; Reject
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="toggleMode"
class="px-3 py-2 text-sm border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Auto' : '&#x1F512; Review' }}
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling"