kin: KIN-091 Улучшения из исследования рынка: (1) Revise button с feedback loop, (2) auto-test before review — агент сам прогоняет тесты и фиксит до review, (3) spec-driven workflow для новых проектов — constitution → spec → plan → tasks, (4) git worktrees для параллельных агентов без конфликтов, (5) auto-trigger pipeline при создании задачи с label auto
This commit is contained in:
parent
0cc063d47a
commit
0ccd451b4b
14 changed files with 1660 additions and 18 deletions
90
web/api.py
90
web/api.py
|
|
@ -99,6 +99,32 @@ def get_conn():
|
|||
return init_db(DB_PATH)
|
||||
|
||||
|
||||
def _launch_pipeline_subprocess(task_id: str) -> None:
|
||||
"""Spawn `cli.main run {task_id}` in a detached background subprocess.
|
||||
|
||||
Used by auto-trigger (label 'auto') and revise endpoint.
|
||||
Never raises — subprocess errors are logged only.
|
||||
"""
|
||||
import os
|
||||
kin_root = Path(__file__).parent.parent
|
||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
|
||||
cmd.append("--allow-write")
|
||||
env = os.environ.copy()
|
||||
env["KIN_NONINTERACTIVE"] = "1"
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(kin_root),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
_logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid)
|
||||
except Exception as exc:
|
||||
_logger.warning("Failed to launch pipeline for %s: %s", task_id, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -193,6 +219,7 @@ class ProjectCreate(BaseModel):
|
|||
class ProjectPatch(BaseModel):
|
||||
execution_mode: str | None = None
|
||||
autocommit_enabled: bool | None = None
|
||||
auto_test_enabled: bool | None = None
|
||||
obsidian_vault_path: str | None = None
|
||||
deploy_command: str | None = None
|
||||
project_type: str | None = None
|
||||
|
|
@ -206,6 +233,7 @@ class ProjectPatch(BaseModel):
|
|||
def patch_project(project_id: str, body: ProjectPatch):
|
||||
has_any = any([
|
||||
body.execution_mode, body.autocommit_enabled is not None,
|
||||
body.auto_test_enabled is not None,
|
||||
body.obsidian_vault_path, body.deploy_command is not None,
|
||||
body.project_type, body.ssh_host is not None,
|
||||
body.ssh_user is not None, body.ssh_key_path is not None,
|
||||
|
|
@ -227,6 +255,8 @@ def patch_project(project_id: str, body: ProjectPatch):
|
|||
fields["execution_mode"] = body.execution_mode
|
||||
if body.autocommit_enabled is not None:
|
||||
fields["autocommit_enabled"] = int(body.autocommit_enabled)
|
||||
if body.auto_test_enabled is not None:
|
||||
fields["auto_test_enabled"] = int(body.auto_test_enabled)
|
||||
if body.obsidian_vault_path is not None:
|
||||
fields["obsidian_vault_path"] = body.obsidian_vault_path
|
||||
if body.deploy_command is not None:
|
||||
|
|
@ -527,6 +557,7 @@ class TaskCreate(BaseModel):
|
|||
route_type: str | None = None
|
||||
category: str | None = None
|
||||
acceptance_criteria: str | None = None
|
||||
labels: list[str] | None = None
|
||||
|
||||
|
||||
@app.post("/api/tasks")
|
||||
|
|
@ -546,8 +577,14 @@ def create_task(body: TaskCreate):
|
|||
brief = {"route_type": body.route_type} if body.route_type else None
|
||||
t = models.create_task(conn, task_id, body.project_id, body.title,
|
||||
priority=body.priority, brief=brief, category=category,
|
||||
acceptance_criteria=body.acceptance_criteria)
|
||||
acceptance_criteria=body.acceptance_criteria,
|
||||
labels=body.labels)
|
||||
conn.close()
|
||||
|
||||
# Auto-trigger: if task has 'auto' label, launch pipeline in background
|
||||
if body.labels and "auto" in body.labels:
|
||||
_launch_pipeline_subprocess(task_id)
|
||||
|
||||
return t
|
||||
|
||||
|
||||
|
|
@ -763,21 +800,66 @@ def reject_task(task_id: str, body: TaskReject):
|
|||
return {"status": "pending", "reason": body.reason}
|
||||
|
||||
|
||||
_MAX_REVISE_COUNT = 5
|
||||
|
||||
|
||||
class TaskRevise(BaseModel):
|
||||
comment: str
|
||||
steps: list[dict] | None = None # override pipeline steps (optional)
|
||||
target_role: str | None = None # if set, re-run only [target_role, reviewer] instead of full pipeline
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/revise")
|
||||
def revise_task(task_id: str, body: TaskRevise):
|
||||
"""Revise a task: return to in_progress with director's comment for the agent."""
|
||||
"""Revise a task: update comment, increment revise_count, and re-run pipeline."""
|
||||
if not body.comment.strip():
|
||||
raise HTTPException(400, "comment must not be empty")
|
||||
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status="in_progress", revise_comment=body.comment)
|
||||
|
||||
revise_count = (t.get("revise_count") or 0) + 1
|
||||
if revise_count > _MAX_REVISE_COUNT:
|
||||
conn.close()
|
||||
raise HTTPException(400, f"Max revisions ({_MAX_REVISE_COUNT}) reached for this task")
|
||||
|
||||
models.update_task(
|
||||
conn, task_id,
|
||||
status="in_progress",
|
||||
revise_comment=body.comment,
|
||||
revise_count=revise_count,
|
||||
revise_target_role=body.target_role,
|
||||
)
|
||||
|
||||
# Resolve steps: explicit > target_role shortcut > last pipeline steps
|
||||
steps = body.steps
|
||||
if not steps:
|
||||
if body.target_role:
|
||||
steps = [{"role": body.target_role}, {"role": "reviewer"}]
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT steps FROM pipelines WHERE task_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
import json as _json
|
||||
raw = row["steps"]
|
||||
steps = _json.loads(raw) if isinstance(raw, str) else raw
|
||||
|
||||
conn.close()
|
||||
return {"status": "in_progress", "comment": body.comment}
|
||||
|
||||
# Launch pipeline in background subprocess
|
||||
_launch_pipeline_subprocess(task_id)
|
||||
|
||||
return {
|
||||
"status": "in_progress",
|
||||
"comment": body.comment,
|
||||
"revise_count": revise_count,
|
||||
"pipeline_steps": steps,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/running")
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export interface Task {
|
|||
dangerously_skipped: number | null
|
||||
category: string | null
|
||||
acceptance_criteria: string | null
|
||||
feedback?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,34 @@ const startPhaseSaving = ref(false)
|
|||
const approvePhaseSaving = ref(false)
|
||||
let phasePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Task Revise
|
||||
const showTaskReviseModal = ref(false)
|
||||
const taskReviseTaskId = ref<string | null>(null)
|
||||
const taskReviseComment = ref('')
|
||||
const taskReviseError = ref('')
|
||||
const taskReviseSaving = ref(false)
|
||||
|
||||
function openTaskRevise(taskId: string) {
|
||||
taskReviseTaskId.value = taskId
|
||||
taskReviseComment.value = ''
|
||||
taskReviseError.value = ''
|
||||
showTaskReviseModal.value = true
|
||||
}
|
||||
|
||||
async function submitTaskRevise() {
|
||||
if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return }
|
||||
taskReviseSaving.value = true
|
||||
try {
|
||||
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
|
||||
showTaskReviseModal.value = false
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
taskReviseError.value = e.message
|
||||
} finally {
|
||||
taskReviseSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function checkAndPollPhases() {
|
||||
const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress')
|
||||
if (hasRunning && !phasePollTimer) {
|
||||
|
|
@ -143,7 +171,7 @@ function phaseStatusColor(s: string) {
|
|||
}
|
||||
|
||||
// Filters
|
||||
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
|
||||
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'revising', 'cancelled']
|
||||
|
||||
function initStatusFilter(): string[] {
|
||||
const q = route.query.status as string
|
||||
|
|
@ -333,6 +361,21 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||
const showAddTask = ref(false)
|
||||
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
|
||||
const taskFormError = ref('')
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
pendingFiles.value.push(...Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeAddTaskModal() {
|
||||
showAddTask.value = false
|
||||
pendingFiles.value = []
|
||||
}
|
||||
|
||||
// Add decision modal
|
||||
const showAddDecision = ref(false)
|
||||
|
|
@ -421,6 +464,7 @@ function taskStatusColor(s: string) {
|
|||
const m: Record<string, string> = {
|
||||
pending: 'gray', in_progress: 'blue', review: 'purple',
|
||||
done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray',
|
||||
revising: 'orange',
|
||||
}
|
||||
return m[s] || 'gray'
|
||||
}
|
||||
|
|
@ -449,7 +493,7 @@ const decTypes = computed(() => {
|
|||
async function addTask() {
|
||||
taskFormError.value = ''
|
||||
try {
|
||||
await api.createTask({
|
||||
const task = await api.createTask({
|
||||
project_id: props.id,
|
||||
title: taskForm.value.title,
|
||||
priority: taskForm.value.priority,
|
||||
|
|
@ -457,6 +501,20 @@ async function addTask() {
|
|||
category: taskForm.value.category || undefined,
|
||||
acceptance_criteria: taskForm.value.acceptance_criteria || undefined,
|
||||
})
|
||||
if (pendingFiles.value.length > 0) {
|
||||
const failedFiles: string[] = []
|
||||
for (const file of pendingFiles.value) {
|
||||
try {
|
||||
await api.uploadAttachment(task.id, file)
|
||||
} catch {
|
||||
failedFiles.push(file.name)
|
||||
}
|
||||
}
|
||||
pendingFiles.value = []
|
||||
if (failedFiles.length > 0) {
|
||||
console.warn('Failed to upload attachments:', failedFiles)
|
||||
}
|
||||
}
|
||||
showAddTask.value = false
|
||||
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
|
||||
await load()
|
||||
|
|
@ -798,6 +856,12 @@ async function addDecision() {
|
|||
</button>
|
||||
<span v-if="t.status === 'in_progress'"
|
||||
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
|
||||
<button v-if="t.status === 'review' || t.status === 'done'"
|
||||
@click.prevent.stop="openTaskRevise(t.id)"
|
||||
class="px-2 py-0.5 bg-orange-900/40 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 text-[10px]"
|
||||
title="Отправить на доработку">
|
||||
↩ Revise
|
||||
</button>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -1092,7 +1156,7 @@ async function addDecision() {
|
|||
</div>
|
||||
|
||||
<!-- Add Task Modal -->
|
||||
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
||||
<Modal v-if="showAddTask" title="Add Task" @close="closeAddTaskModal">
|
||||
<form @submit.prevent="addTask" class="space-y-3">
|
||||
<input v-model="taskForm.title" placeholder="Task title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
|
|
@ -1117,6 +1181,25 @@ async function addDecision() {
|
|||
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<div v-if="project?.path">
|
||||
<label class="block text-xs text-gray-500 mb-1">Вложения</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="fileInputRef?.click()"
|
||||
class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-400 hover:bg-gray-700 hover:text-gray-200">
|
||||
Прикрепить файлы
|
||||
</button>
|
||||
<span v-if="pendingFiles.length" class="text-xs text-gray-500">{{ pendingFiles.length }} файл(ов)</span>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" multiple class="hidden" @change="onFileSelect" />
|
||||
<ul v-if="pendingFiles.length" class="mt-2 space-y-1">
|
||||
<li v-for="(file, i) in pendingFiles" :key="i"
|
||||
class="flex items-center justify-between text-xs text-gray-400 bg-gray-800/50 rounded px-2 py-1">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<button type="button" @click="pendingFiles.splice(i, 1)"
|
||||
class="ml-2 text-gray-600 hover:text-red-400 flex-shrink-0">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
|
|
@ -1218,6 +1301,20 @@ async function addDecision() {
|
|||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Task Revise Modal -->
|
||||
<Modal v-if="showTaskReviseModal" title="Отправить на доработку" @close="showTaskReviseModal = false">
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-gray-500">Задача <span class="text-orange-400">{{ taskReviseTaskId }}</span> вернётся в pipeline с вашим комментарием.</p>
|
||||
<textarea v-model="taskReviseComment" placeholder="Что нужно доработать?" rows="4"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
|
||||
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
|
||||
<button @click="submitTaskRevise" :disabled="taskReviseSaving"
|
||||
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900 disabled:opacity-50">
|
||||
{{ taskReviseSaving ? 'Отправляем...' : 'Отправить на доработку' }}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Audit Modal -->
|
||||
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
||||
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue