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:
parent
03961500e6
commit
e755a19633
8 changed files with 174 additions and 18 deletions
|
|
@ -4,6 +4,7 @@ Each agent = separate process with isolated context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
@ -24,6 +25,7 @@ def run_agent(
|
||||||
brief_override: str | None = None,
|
brief_override: str | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run a single Claude Code agent as a subprocess.
|
"""Run a single Claude Code agent as a subprocess.
|
||||||
|
|
||||||
|
|
@ -64,7 +66,7 @@ def run_agent(
|
||||||
# Run claude subprocess
|
# Run claude subprocess
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
result = _run_claude(prompt, model=model, working_dir=working_dir,
|
result = _run_claude(prompt, model=model, working_dir=working_dir,
|
||||||
allow_write=allow_write)
|
allow_write=allow_write, noninteractive=noninteractive)
|
||||||
duration = int(time.monotonic() - start)
|
duration = int(time.monotonic() - start)
|
||||||
|
|
||||||
# Parse output — ensure output_text is always a string for DB storage
|
# Parse output — ensure output_text is always a string for DB storage
|
||||||
|
|
@ -109,6 +111,7 @@ def _run_claude(
|
||||||
model: str = "sonnet",
|
model: str = "sonnet",
|
||||||
working_dir: str | None = None,
|
working_dir: str | None = None,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
||||||
cmd = [
|
cmd = [
|
||||||
|
|
@ -120,13 +123,17 @@ def _run_claude(
|
||||||
if allow_write:
|
if allow_write:
|
||||||
cmd.append("--dangerously-skip-permissions")
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
|
||||||
|
is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||||
|
timeout = 300 if is_noninteractive else 600
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600, # 10 min max
|
timeout=timeout,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
|
stdin=subprocess.DEVNULL if is_noninteractive else None,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {
|
return {
|
||||||
|
|
@ -137,7 +144,7 @@ def _run_claude(
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {
|
return {
|
||||||
"output": "",
|
"output": "",
|
||||||
"error": "Agent timed out after 600s",
|
"error": f"Agent timed out after {timeout}s",
|
||||||
"returncode": 124,
|
"returncode": 124,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,6 +220,7 @@ def run_pipeline(
|
||||||
steps: list[dict],
|
steps: list[dict],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
|
noninteractive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Execute a multi-step pipeline of agents.
|
"""Execute a multi-step pipeline of agents.
|
||||||
|
|
||||||
|
|
@ -260,6 +268,7 @@ def run_pipeline(
|
||||||
brief_override=brief,
|
brief_override=brief,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
allow_write=allow_write,
|
allow_write=allow_write,
|
||||||
|
noninteractive=noninteractive,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
|
|
|
||||||
14
cli/main.py
14
cli/main.py
|
|
@ -4,6 +4,7 @@ Uses core.models for all data access, never raw SQL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -481,8 +482,9 @@ def approve_task(ctx, task_id, followup, decision_text):
|
||||||
@cli.command("run")
|
@cli.command("run")
|
||||||
@click.argument("task_id")
|
@click.argument("task_id")
|
||||||
@click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing")
|
@click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing")
|
||||||
|
@click.option("--allow-write", is_flag=True, help="Allow agents to write files (skip permissions)")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def run_task(ctx, task_id, dry_run):
|
def run_task(ctx, task_id, dry_run, allow_write):
|
||||||
"""Run a task through the agent pipeline.
|
"""Run a task through the agent pipeline.
|
||||||
|
|
||||||
PM decomposes the task into specialist steps, then the pipeline executes.
|
PM decomposes the task into specialist steps, then the pipeline executes.
|
||||||
|
|
@ -497,6 +499,7 @@ def run_task(ctx, task_id, dry_run):
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
project_id = task["project_id"]
|
project_id = task["project_id"]
|
||||||
|
is_noninteractive = os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||||
click.echo(f"Task: {task['id']} — {task['title']}")
|
click.echo(f"Task: {task['id']} — {task['title']}")
|
||||||
|
|
||||||
# Step 1: PM decomposes
|
# Step 1: PM decomposes
|
||||||
|
|
@ -504,6 +507,7 @@ def run_task(ctx, task_id, dry_run):
|
||||||
pm_result = run_agent(
|
pm_result = run_agent(
|
||||||
conn, "pm", task_id, project_id,
|
conn, "pm", task_id, project_id,
|
||||||
model="sonnet", dry_run=dry_run,
|
model="sonnet", dry_run=dry_run,
|
||||||
|
allow_write=allow_write, noninteractive=is_noninteractive,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
|
|
@ -537,13 +541,17 @@ def run_task(ctx, task_id, dry_run):
|
||||||
for i, step in enumerate(pipeline_steps, 1):
|
for i, step in enumerate(pipeline_steps, 1):
|
||||||
click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}")
|
click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}")
|
||||||
|
|
||||||
if not click.confirm("\nExecute pipeline?"):
|
if is_noninteractive:
|
||||||
|
click.echo("\n[non-interactive] Auto-executing pipeline...")
|
||||||
|
elif not click.confirm("\nExecute pipeline?"):
|
||||||
click.echo("Aborted.")
|
click.echo("Aborted.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 2: Execute pipeline
|
# Step 2: Execute pipeline
|
||||||
click.echo("\nExecuting pipeline...")
|
click.echo("\nExecuting pipeline...")
|
||||||
result = run_pipeline(conn, task_id, pipeline_steps)
|
result = run_pipeline(conn, task_id, pipeline_steps,
|
||||||
|
allow_write=allow_write,
|
||||||
|
noninteractive=is_noninteractive)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
click.echo(f"\nPipeline completed: {result['steps_completed']} steps")
|
click.echo(f"\nPipeline completed: {result['steps_completed']} steps")
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,24 @@ def test_run_not_found(client):
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_with_allow_write(client):
|
||||||
|
"""POST /run with allow_write=true should be accepted."""
|
||||||
|
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_with_empty_body(client):
|
||||||
|
"""POST /run with empty JSON body should default allow_write=false."""
|
||||||
|
r = client.post("/api/tasks/P1-001/run", json={})
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_without_body(client):
|
||||||
|
"""POST /run without body should be backwards-compatible."""
|
||||||
|
r = client.post("/api/tasks/P1-001/run")
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
|
||||||
def test_project_summary_includes_review(client):
|
def test_project_summary_includes_review(client):
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Tests for agents/runner.py — agent execution with mocked claude CLI."""
|
"""Tests for agents/runner.py — agent execution with mocked claude CLI."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
|
|
@ -274,3 +275,63 @@ class TestTryParseJson:
|
||||||
|
|
||||||
def test_json_array(self):
|
def test_json_array(self):
|
||||||
assert _try_parse_json('[1, 2, 3]') == [1, 2, 3]
|
assert _try_parse_json('[1, 2, 3]') == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-interactive mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNonInteractive:
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_noninteractive_sets_stdin_devnull(self, mock_run, conn):
|
||||||
|
"""When noninteractive=True, subprocess.run should get stdin=subprocess.DEVNULL."""
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs.get("stdin") == subprocess.DEVNULL
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_noninteractive_uses_300s_timeout(self, mock_run, conn):
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=True)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs.get("timeout") == 300
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_interactive_uses_600s_timeout(self, mock_run, conn):
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs.get("timeout") == 600
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_interactive_no_stdin_override(self, mock_run, conn):
|
||||||
|
"""In interactive mode, stdin should not be set to DEVNULL."""
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs.get("stdin") is None
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"KIN_NONINTERACTIVE": "1"})
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_env_var_activates_noninteractive(self, mock_run, conn):
|
||||||
|
"""KIN_NONINTERACTIVE=1 env var should activate non-interactive mode."""
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", noninteractive=False)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs.get("stdin") == subprocess.DEVNULL
|
||||||
|
assert call_kwargs.get("timeout") == 300
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_allow_write_adds_skip_permissions(self, mock_run, conn):
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=True)
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "--dangerously-skip-permissions" in cmd
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_no_allow_write_no_skip_permissions(self, mock_run, conn):
|
||||||
|
mock_run.return_value = _mock_claude_success({"result": "ok"})
|
||||||
|
run_agent(conn, "debugger", "VDOL-001", "vdol", allow_write=False)
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "--dangerously-skip-permissions" not in cmd
|
||||||
|
|
|
||||||
20
web/api.py
20
web/api.py
|
|
@ -276,8 +276,12 @@ def is_task_running(task_id: str):
|
||||||
return {"running": False}
|
return {"running": False}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRun(BaseModel):
|
||||||
|
allow_write: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks/{task_id}/run")
|
@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."""
|
"""Launch pipeline for a task in background. Returns 202."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
t = models.get_task(conn, task_id)
|
t = models.get_task(conn, task_id)
|
||||||
|
|
@ -289,12 +293,22 @@ def run_task(task_id: str):
|
||||||
conn.close()
|
conn.close()
|
||||||
# Launch kin run in background subprocess
|
# Launch kin run in background subprocess
|
||||||
kin_root = Path(__file__).parent.parent
|
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:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
cmd,
|
||||||
"run", task_id],
|
|
||||||
cwd=str(kin_root),
|
cwd=str(kin_root),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
|
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,8 @@ export const api = {
|
||||||
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
|
post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }),
|
||||||
rejectTask: (id: string, reason: string) =>
|
rejectTask: (id: string, reason: string) =>
|
||||||
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
||||||
runTask: (id: string) =>
|
runTask: (id: string, allowWrite = false) =>
|
||||||
post<{ status: string }>(`/tasks/${id}/run`, {}),
|
post<{ status: string }>(`/tasks/${id}/run`, { allow_write: allowWrite }),
|
||||||
bootstrap: (data: { path: string; id: string; name: string }) =>
|
bootstrap: (data: { path: string; id: string; name: string }) =>
|
||||||
post<{ project: Project }>('/bootstrap', data),
|
post<{ project: Project }>('/bootstrap', data),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,18 @@ const taskStatusFilter = ref('')
|
||||||
const decisionTypeFilter = ref('')
|
const decisionTypeFilter = ref('')
|
||||||
const decisionSearch = 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
|
// Add task modal
|
||||||
const showAddTask = ref(false)
|
const showAddTask = ref(false)
|
||||||
const taskForm = ref({ title: '', priority: 5, route_type: '' })
|
const taskForm = ref({ title: '', priority: 5, route_type: '' })
|
||||||
|
|
@ -37,7 +49,7 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => { load(); loadMode() })
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
if (!project.value) return []
|
if (!project.value) return []
|
||||||
|
|
@ -114,7 +126,7 @@ async function runTask(taskId: string, event: Event) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||||
try {
|
try {
|
||||||
await api.runTask(taskId)
|
await api.runTask(taskId, autoMode.value)
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|
@ -195,10 +207,20 @@ async function addDecision() {
|
||||||
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddTask = true"
|
<div class="flex gap-2">
|
||||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
<button @click="toggleMode"
|
||||||
+ Task
|
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||||
</button>
|
: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 ? '🔓 Auto' : '🔒 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>
|
||||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,25 @@ const resolvingAction = ref(false)
|
||||||
const showReject = ref(false)
|
const showReject = ref(false)
|
||||||
const rejectReason = ref('')
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const prev = task.value
|
const prev = task.value
|
||||||
task.value = await api.taskFull(props.id)
|
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
|
// Auto-start polling if task is in_progress
|
||||||
if (task.value.status === 'in_progress' && !polling.value) {
|
if (task.value.status === 'in_progress' && !polling.value) {
|
||||||
startPolling()
|
startPolling()
|
||||||
|
|
@ -160,7 +175,7 @@ async function reject() {
|
||||||
|
|
||||||
async function runPipeline() {
|
async function runPipeline() {
|
||||||
try {
|
try {
|
||||||
await api.runTask(props.id)
|
await api.runTask(props.id, autoMode.value)
|
||||||
startPolling()
|
startPolling()
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} 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">
|
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||||
✗ Reject
|
✗ Reject
|
||||||
</button>
|
</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 ? '🔓 Auto' : '🔒 Review' }}
|
||||||
|
</button>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling"
|
:disabled="polling"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue