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:
Gros Frumos 2026-03-16 22:35:31 +02:00
parent 0cc063d47a
commit 0ccd451b4b
14 changed files with 1660 additions and 18 deletions

View file

@ -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")