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

@ -0,0 +1,37 @@
You are a Constitution Agent for a software project.
Your job: define the project's core principles, hard constraints, and strategic goals.
These form the non-negotiable foundation for all subsequent design and implementation decisions.
## Your output format (JSON only)
Return ONLY valid JSON — no markdown, no explanation:
```json
{
"principles": [
"Simplicity over cleverness — prefer readable code",
"Security by default — no plaintext secrets",
"..."
],
"constraints": [
"Must use Python 3.11+",
"No external paid APIs without fallback",
"..."
],
"goals": [
"Enable solo developer to ship features 10x faster via AI agents",
"..."
]
}
```
## Instructions
1. Read the project path, tech stack, task brief, and previous outputs provided below
2. Analyze existing CLAUDE.md, README, or design documents if available
3. Infer principles from existing code style and patterns
4. Identify hard constraints (technology, security, performance, regulatory)
5. Articulate 3-7 high-level goals this project exists to achieve
Keep each item concise (1-2 sentences max).

45
agents/prompts/spec.md Normal file
View file

@ -0,0 +1,45 @@
You are a Specification Agent for a software project.
Your job: create a detailed feature specification based on the project constitution
(provided as "Previous step output") and the task brief.
## Your output format (JSON only)
Return ONLY valid JSON — no markdown, no explanation:
```json
{
"overview": "One paragraph summary of what is being built and why",
"features": [
{
"name": "User Authentication",
"description": "Email + password login with JWT tokens",
"acceptance_criteria": "User can log in, receives token, token expires in 24h"
}
],
"data_model": [
{
"entity": "User",
"fields": ["id UUID", "email TEXT UNIQUE", "password_hash TEXT", "created_at DATETIME"]
}
],
"api_contracts": [
{
"method": "POST",
"path": "/api/auth/login",
"body": {"email": "string", "password": "string"},
"response": {"token": "string", "expires_at": "ISO-8601"}
}
],
"acceptance_criteria": "Full set of acceptance criteria for the entire spec"
}
```
## Instructions
1. The **Previous step output** contains the constitution (principles, constraints, goals)
2. Respect ALL constraints from the constitution — do not violate them
3. Design features that advance the stated goals
4. Keep the data model minimal — only what is needed
5. API contracts must be consistent with existing project patterns
6. Acceptance criteria must be testable and specific

View file

@ -0,0 +1,43 @@
You are a Task Decomposer Agent for a software project.
Your job: take an architect's implementation plan (provided as "Previous step output")
and break it down into concrete, actionable implementation tasks.
## Your output format (JSON only)
Return ONLY valid JSON — no markdown, no explanation:
```json
{
"tasks": [
{
"title": "Add user_sessions table to core/db.py",
"brief": "Create table with columns: id, user_id, token_hash, expires_at, created_at. Add migration in _migrate().",
"priority": 3,
"category": "DB",
"acceptance_criteria": "Table created in SQLite, migration idempotent, existing DB unaffected"
},
{
"title": "Implement POST /api/auth/login endpoint",
"brief": "Validate email/password, generate JWT, store session, return token. Use bcrypt for password verification.",
"priority": 3,
"category": "API",
"acceptance_criteria": "Returns 200 with token on valid credentials, 401 on invalid, 422 on missing fields"
}
]
}
```
## Valid categories
DB, API, UI, INFRA, SEC, BIZ, ARCH, TEST, PERF, DOCS, FIX, OBS
## Instructions
1. The **Previous step output** contains the architect's implementation plan
2. Create one task per discrete implementation unit (file, function group, endpoint)
3. Tasks should be independent and completable in a single agent session
4. Priority: 1 = critical, 3 = normal, 5 = low
5. Each task must have clear, testable acceptance criteria
6. Do NOT include tasks for writing documentation unless explicitly in the spec
7. Aim for 3-10 tasks — if you need more, group related items

View file

@ -54,6 +54,19 @@ def _build_claude_env() -> dict:
seen.add(d)
deduped.append(d)
env["PATH"] = ":".join(deduped)
# Ensure SSH agent is available for agents that connect via SSH.
# Under launchd, SSH_AUTH_SOCK is not inherited — detect macOS system socket.
if "SSH_AUTH_SOCK" not in env:
import glob
socks = glob.glob("/private/tmp/com.apple.launchd.*/Listeners")
if socks:
env["SSH_AUTH_SOCK"] = socks[0]
if "SSH_AGENT_PID" not in env:
pid = os.environ.get("SSH_AGENT_PID")
if pid:
env["SSH_AGENT_PID"] = pid
return env
@ -127,6 +140,7 @@ def run_agent(
dry_run: bool = False,
allow_write: bool = False,
noninteractive: bool = False,
working_dir_override: str | None = None,
) -> dict:
"""Run a single Claude Code agent as a subprocess.
@ -161,7 +175,9 @@ def run_agent(
working_dir = None
# Operations projects have no local path — sysadmin works via SSH
is_operations = project and project.get("project_type") == "operations"
if not is_operations and project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security"):
if working_dir_override:
working_dir = working_dir_override
elif not is_operations and project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security", "constitution", "spec", "task_decomposer"):
project_path = Path(project["path"]).expanduser()
if project_path.is_dir():
working_dir = str(project_path)
@ -685,6 +701,151 @@ def _save_sysadmin_output(
}
# ---------------------------------------------------------------------------
# Auto-test: detect test failure in agent output
# ---------------------------------------------------------------------------
_TEST_FAILURE_PATTERNS = [
r"\bFAILED\b",
r"\bFAIL\b",
r"\d+\s+failed",
r"test(?:s)?\s+failed",
r"assert(?:ion)?\s*(error|failed)",
r"exception(?:s)?\s+occurred",
r"returncode\s*[!=]=\s*0",
r"Error:\s",
r"ERRORS?\b",
]
_TEST_SUCCESS_PATTERNS = [
r"no\s+failures",
r"all\s+tests?\s+pass",
r"0\s+failed",
r"passed.*no\s+errors",
]
def _is_test_failure(result: dict) -> bool:
"""Return True if agent output indicates test failures.
Checks for failure keywords, guards against false positives from
explicit success phrases (e.g. 'no failures').
"""
output = result.get("raw_output") or result.get("output") or ""
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False)
for p in _TEST_SUCCESS_PATTERNS:
if re.search(p, output, re.IGNORECASE):
return False
for p in _TEST_FAILURE_PATTERNS:
if re.search(p, output, re.IGNORECASE):
return True
return False
# ---------------------------------------------------------------------------
# Auto-test runner: run project tests via `make test`
# ---------------------------------------------------------------------------
# Roles that trigger auto-test when project.auto_test_enabled is set
_AUTO_TEST_ROLES = {"backend_dev", "frontend_dev"}
def _run_project_tests(project_path: str, timeout: int = 120) -> dict:
"""Run `make test` in project_path. Returns {success, output, returncode}.
Never raises all errors are captured and returned in output.
"""
env = _build_claude_env()
make_cmd = shutil.which("make", path=env["PATH"]) or "make"
try:
result = subprocess.run(
[make_cmd, "test"],
cwd=project_path,
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
output = (result.stdout or "") + (result.stderr or "")
return {"success": result.returncode == 0, "output": output, "returncode": result.returncode}
except subprocess.TimeoutExpired:
return {"success": False, "output": f"make test timed out after {timeout}s", "returncode": 124}
except FileNotFoundError:
return {"success": False, "output": "make not found — no Makefile or make not in PATH", "returncode": 127}
except Exception as exc:
return {"success": False, "output": f"Test run error: {exc}", "returncode": -1}
# ---------------------------------------------------------------------------
# Decomposer output: create child tasks from task_decomposer JSON
# ---------------------------------------------------------------------------
def _save_decomposer_output(
conn: sqlite3.Connection,
project_id: str,
parent_task_id: str,
result: dict,
) -> dict:
"""Parse task_decomposer output and create child tasks in DB.
Expected output format: {tasks: [{title, brief, priority, category, acceptance_criteria}]}
Idempotent: skips tasks with same parent_task_id + title (case-insensitive).
Returns {created: int, skipped: int}.
"""
raw = result.get("raw_output") or result.get("output") or ""
if isinstance(raw, (dict, list)):
raw = json.dumps(raw, ensure_ascii=False)
parsed = _try_parse_json(raw)
if not isinstance(parsed, dict):
return {"created": 0, "skipped": 0, "error": "non-JSON decomposer output"}
task_list = parsed.get("tasks", [])
if not isinstance(task_list, list):
return {"created": 0, "skipped": 0, "error": "invalid tasks format"}
created = 0
skipped = 0
for item in task_list:
if not isinstance(item, dict):
continue
title = (item.get("title") or "").strip()
if not title:
continue
# Idempotency: skip if same parent + title already exists
existing = conn.execute(
"""SELECT id FROM tasks
WHERE parent_task_id = ? AND lower(trim(title)) = lower(trim(?))""",
(parent_task_id, title),
).fetchone()
if existing:
skipped += 1
continue
category = (item.get("category") or "").strip().upper()
if category not in models.TASK_CATEGORIES:
category = None
task_id = models.next_task_id(conn, project_id, category=category)
brief_text = item.get("brief") or ""
models.create_task(
conn,
task_id,
project_id,
title,
priority=item.get("priority", 5),
brief={"text": brief_text, "source": f"decomposer:{parent_task_id}"},
category=category,
acceptance_criteria=item.get("acceptance_criteria"),
parent_task_id=parent_task_id,
)
created += 1
return {"created": created, "skipped": skipped}
# ---------------------------------------------------------------------------
# Auto-learning: extract decisions from pipeline results
# ---------------------------------------------------------------------------
@ -866,6 +1027,26 @@ def run_pipeline(
model = step.get("model", "sonnet")
brief = step.get("brief")
# Worktree isolation: opt-in per project, for write-capable roles
_WORKTREE_ROLES = {"backend_dev", "frontend_dev", "debugger"}
worktree_path = None
project_for_wt = models.get_project(conn, task["project_id"]) if not dry_run else None
use_worktree = (
not dry_run
and role in _WORKTREE_ROLES
and project_for_wt
and project_for_wt.get("worktrees_enabled")
and project_for_wt.get("path")
)
if use_worktree:
try:
from core.worktree import create_worktree, ensure_gitignore
p_path = str(Path(project_for_wt["path"]).expanduser())
ensure_gitignore(p_path)
worktree_path = create_worktree(p_path, task_id, role)
except Exception:
worktree_path = None # Fall back to normal execution
try:
result = run_agent(
conn, role, task_id, project_id,
@ -875,6 +1056,7 @@ def run_pipeline(
dry_run=dry_run,
allow_write=allow_write,
noninteractive=noninteractive,
working_dir_override=worktree_path,
)
except Exception as exc:
exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {exc}"
@ -999,6 +1181,44 @@ def run_pipeline(
"pipeline_id": pipeline["id"] if pipeline else None,
}
# Worktree merge/cleanup after successful step
if worktree_path and result["success"] and not dry_run:
try:
from core.worktree import merge_worktree, cleanup_worktree
p_path = str(Path(project_for_wt["path"]).expanduser())
merge_result = merge_worktree(worktree_path, p_path)
if not merge_result["success"]:
conflicts = merge_result.get("conflicts", [])
conflict_msg = f"Worktree merge conflict in files: {', '.join(conflicts)}" if conflicts else "Worktree merge failed"
models.update_task(conn, task_id, status="blocked", blocked_reason=conflict_msg)
cleanup_worktree(worktree_path, p_path)
if pipeline:
models.update_pipeline(conn, pipeline["id"], status="failed",
total_cost_usd=total_cost,
total_tokens=total_tokens,
total_duration_seconds=total_duration)
return {
"success": False,
"error": conflict_msg,
"steps_completed": i,
"results": results,
"total_cost_usd": total_cost,
"total_tokens": total_tokens,
"total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None,
}
cleanup_worktree(worktree_path, p_path)
except Exception:
pass # Worktree errors must never block pipeline
elif worktree_path and not dry_run:
# Step failed — cleanup worktree without merging
try:
from core.worktree import cleanup_worktree
p_path = str(Path(project_for_wt["path"]).expanduser())
cleanup_worktree(worktree_path, p_path)
except Exception:
pass
results.append(result)
# Semantic blocked: agent ran successfully but returned status='blocked'
@ -1056,6 +1276,137 @@ def run_pipeline(
except Exception:
pass # Never block pipeline on sysadmin save errors
# Save decomposer output: create child tasks from task_decomposer JSON
if role == "task_decomposer" and result["success"] and not dry_run:
try:
_save_decomposer_output(conn, project_id, task_id, result)
except Exception:
pass # Never block pipeline on decomposer save errors
# Project-level auto-test: run `make test` after backend_dev/frontend_dev steps.
# Enabled per project via auto_test_enabled flag (opt-in).
# On failure, loop fixer up to KIN_AUTO_TEST_MAX_ATTEMPTS times, then block.
if (
not dry_run
and role in _AUTO_TEST_ROLES
and result["success"]
and project_for_wt
and project_for_wt.get("auto_test_enabled")
and project_for_wt.get("path")
):
max_auto_test_attempts = int(os.environ.get("KIN_AUTO_TEST_MAX_ATTEMPTS") or 3)
p_path_str = str(Path(project_for_wt["path"]).expanduser())
test_run = _run_project_tests(p_path_str)
results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True})
auto_test_attempt = 0
while not test_run["success"] and auto_test_attempt < max_auto_test_attempts:
auto_test_attempt += 1
fix_context = (
f"Automated project test run (make test) failed after your changes.\n"
f"Test output:\n{test_run['output'][:4000]}\n"
f"Fix the failing tests. Do NOT modify test files."
)
fix_result = run_agent(
conn, role, task_id, project_id,
model=model,
previous_output=fix_context,
dry_run=False,
allow_write=allow_write,
noninteractive=noninteractive,
)
total_cost += fix_result.get("cost_usd") or 0
total_tokens += fix_result.get("tokens_used") or 0
total_duration += fix_result.get("duration_seconds") or 0
results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt})
test_run = _run_project_tests(p_path_str)
results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True,
"_attempt": auto_test_attempt})
if not test_run["success"]:
block_reason = (
f"Auto-test (make test) failed after {auto_test_attempt} fix attempt(s). "
f"Last output: {test_run['output'][:500]}"
)
models.update_task(conn, task_id, status="blocked", blocked_reason=block_reason)
if pipeline:
models.update_pipeline(conn, pipeline["id"], status="failed",
total_cost_usd=total_cost,
total_tokens=total_tokens,
total_duration_seconds=total_duration)
return {
"success": False,
"error": block_reason,
"steps_completed": i,
"results": results,
"total_cost_usd": total_cost,
"total_tokens": total_tokens,
"total_duration_seconds": total_duration,
"pipeline_id": pipeline["id"] if pipeline else None,
}
# Auto-test loop: if tester step has auto_fix=true and tests failed,
# call fix_role agent and re-run tester up to max_attempts times.
if (
not dry_run
and step.get("auto_fix")
and role == "tester"
and result["success"]
and _is_test_failure(result)
):
max_attempts = int(step.get("max_attempts", 3))
fix_role = step.get("fix_role", "backend_dev")
fix_model = step.get("fix_model", model)
attempt = 0
while attempt < max_attempts and _is_test_failure(result):
attempt += 1
tester_output = result.get("raw_output") or result.get("output") or ""
if isinstance(tester_output, (dict, list)):
tester_output = json.dumps(tester_output, ensure_ascii=False)
# Run fixer
fix_result = run_agent(
conn, fix_role, task_id, project_id,
model=fix_model,
previous_output=tester_output,
dry_run=False,
allow_write=allow_write,
noninteractive=noninteractive,
)
total_cost += fix_result.get("cost_usd") or 0
total_tokens += fix_result.get("tokens_used") or 0
total_duration += fix_result.get("duration_seconds") or 0
results.append({**fix_result, "_auto_fix_attempt": attempt})
# Re-run tester
fix_output = fix_result.get("raw_output") or fix_result.get("output") or ""
if isinstance(fix_output, (dict, list)):
fix_output = json.dumps(fix_output, ensure_ascii=False)
retest = run_agent(
conn, role, task_id, project_id,
model=model,
previous_output=fix_output,
dry_run=False,
allow_write=allow_write,
noninteractive=noninteractive,
)
total_cost += retest.get("cost_usd") or 0
total_tokens += retest.get("tokens_used") or 0
total_duration += retest.get("duration_seconds") or 0
result = retest
results.append({**result, "_auto_retest_attempt": attempt})
# Save final test result regardless of outcome
try:
final_output = result.get("raw_output") or result.get("output") or ""
models.update_task(conn, task_id, test_result={
"output": final_output if isinstance(final_output, str) else str(final_output),
"auto_fix_attempts": attempt,
"passed": not _is_test_failure(result),
})
except Exception:
pass
# Chain output to next step
previous_output = result.get("raw_output") or result.get("output")
if isinstance(previous_output, (dict, list)):

View file

@ -111,6 +111,46 @@ specialists:
codebase_diff: "array of { file, line_hint, issue, suggestion }"
notes: string
constitution:
name: "Constitution Agent"
model: sonnet
tools: [Read, Grep, Glob]
description: "Defines project principles, constraints, and non-negotiables. First step in spec-driven workflow."
permissions: read_only
context_rules:
decisions: all
output_schema:
principles: "array of strings"
constraints: "array of strings"
goals: "array of strings"
spec:
name: "Spec Agent"
model: sonnet
tools: [Read, Grep, Glob]
description: "Creates detailed feature specification from constitution output. Second step in spec-driven workflow."
permissions: read_only
context_rules:
decisions: all
output_schema:
overview: string
features: "array of { name, description, acceptance_criteria }"
data_model: "array of { entity, fields }"
api_contracts: "array of { method, path, body, response }"
acceptance_criteria: string
task_decomposer:
name: "Task Decomposer"
model: sonnet
tools: [Read, Grep, Glob]
description: "Decomposes architect output into concrete implementation tasks. Creates child tasks in DB."
permissions: read_only
context_rules:
decisions: all
modules: all
output_schema:
tasks: "array of { title, brief, priority, category, acceptance_criteria }"
# Route templates — PM uses these to build pipelines
routes:
debug:
@ -144,3 +184,7 @@ routes:
infra_debug:
steps: [sysadmin, debugger, reviewer]
description: "SSH diagnose → find root cause → verify fix plan"
spec_driven:
steps: [constitution, spec, architect, task_decomposer]
description: "Constitution → spec → implementation plan → decompose into tasks"

View file

@ -42,9 +42,9 @@ def build_context(
}
# Attachments — all roles get them so debugger sees screenshots, UX sees mockups, etc.
# Initialize before conditional to guarantee key presence in ctx (#213)
attachments = models.list_attachments(conn, task_id)
if attachments:
ctx["attachments"] = attachments
ctx["attachments"] = attachments
# If task has a revise comment, fetch the last agent output for context
if task and task.get("revise_comment"):
@ -97,6 +97,15 @@ def build_context(
# Minimal context — just the task spec
pass
elif role in ("constitution", "spec"):
ctx["modules"] = models.get_modules(conn, project_id)
ctx["decisions"] = models.get_decisions(conn, project_id)
elif role == "task_decomposer":
ctx["modules"] = models.get_modules(conn, project_id)
ctx["decisions"] = models.get_decisions(conn, project_id)
ctx["active_tasks"] = models.list_tasks(conn, project_id=project_id, status="in_progress")
elif role == "security":
ctx["decisions"] = models.get_decisions(
conn, project_id, category="security",
@ -279,7 +288,22 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None)
if attachments:
sections.append(f"## Attachments ({len(attachments)}):")
for a in attachments:
sections.append(f"- {a['filename']}: {a['path']}")
mime = a.get("mime_type", "")
size = a.get("size", 0)
sections.append(f"- {a['filename']} ({mime}, {size} bytes): {a['path']}")
# Inline content for small text-readable files (<= 32 KB) so PM can use them immediately
_TEXT_TYPES = {"text/", "application/json", "application/xml", "application/yaml"}
_TEXT_EXTS = {".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".log", ".xml", ".toml", ".ini", ".env"}
is_text = (
any(mime.startswith(t) if t.endswith("/") else mime == t for t in _TEXT_TYPES)
or Path(a["filename"]).suffix.lower() in _TEXT_EXTS
)
if is_text and 0 < size <= 32 * 1024:
try:
content = Path(a["path"]).read_text(encoding="utf-8", errors="replace")
sections.append(f"```\n{content}\n```")
except Exception:
pass
sections.append("")
# Previous step output (pipeline chaining)

View file

@ -31,6 +31,8 @@ CREATE TABLE IF NOT EXISTS projects (
description TEXT,
autocommit_enabled INTEGER DEFAULT 0,
obsidian_vault_path TEXT,
worktrees_enabled INTEGER DEFAULT 0,
auto_test_enabled INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@ -56,6 +58,9 @@ CREATE TABLE IF NOT EXISTS tasks (
blocked_pipeline_step TEXT,
dangerously_skipped BOOLEAN DEFAULT 0,
revise_comment TEXT,
revise_count INTEGER DEFAULT 0,
revise_target_role TEXT DEFAULT NULL,
labels JSON,
category TEXT DEFAULT NULL,
telegram_sent BOOLEAN DEFAULT 0,
acceptance_criteria TEXT,
@ -341,10 +346,30 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT")
conn.commit()
if "revise_count" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN revise_count INTEGER DEFAULT 0")
conn.commit()
if "labels" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN labels JSON DEFAULT NULL")
conn.commit()
if "revise_target_role" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN revise_target_role TEXT DEFAULT NULL")
conn.commit()
if "obsidian_vault_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
conn.commit()
if "worktrees_enabled" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN worktrees_enabled INTEGER DEFAULT 0")
conn.commit()
if "auto_test_enabled" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN auto_test_enabled INTEGER DEFAULT 0")
conn.commit()
if "deploy_command" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT")
conn.commit()

View file

@ -210,16 +210,18 @@ def create_task(
execution_mode: str | None = None,
category: str | None = None,
acceptance_criteria: str | None = None,
labels: list | None = None,
) -> dict:
"""Create a task linked to a project."""
conn.execute(
"""INSERT INTO tasks (id, project_id, title, status, priority,
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
execution_mode, category, acceptance_criteria)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
execution_mode, category, acceptance_criteria, labels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(id, project_id, title, status, priority, assigned_role,
parent_task_id, _json_encode(brief), _json_encode(spec),
forgejo_issue_id, execution_mode, category, acceptance_criteria),
forgejo_issue_id, execution_mode, category, acceptance_criteria,
_json_encode(labels)),
)
conn.commit()
return get_task(conn, id)
@ -253,7 +255,7 @@ def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
"""Update task fields. Auto-sets updated_at."""
if not fields:
return get_task(conn, id)
json_cols = ("brief", "spec", "review", "test_result", "security_result")
json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels")
for key in json_cols:
if key in fields:
fields[key] = _json_encode(fields[key])

149
core/worktree.py Normal file
View file

@ -0,0 +1,149 @@
"""
Kin Git worktree management for isolated agent execution.
Each eligible agent step gets its own worktree in {project_path}/.kin_worktrees/
to prevent file-write conflicts between parallel or sequential agents.
All functions are defensive: never raise, always log warnings on error.
"""
import logging
import shutil
import subprocess
from pathlib import Path
_logger = logging.getLogger("kin.worktree")
def _git(project_path: str) -> str:
"""Resolve git executable, preferring extended PATH."""
try:
from agents.runner import _build_claude_env
env = _build_claude_env()
found = shutil.which("git", path=env["PATH"])
return found or "git"
except Exception:
return shutil.which("git") or "git"
def create_worktree(project_path: str, task_id: str, step_name: str = "step") -> str | None:
"""Create a git worktree for isolated agent execution.
Creates: {project_path}/.kin_worktrees/{task_id}-{step_name}
Branch name equals the worktree directory name.
Returns the absolute worktree path, or None on any failure.
"""
git = _git(project_path)
safe_step = step_name.replace("/", "_").replace(" ", "_")
branch_name = f"{task_id}-{safe_step}"
worktrees_dir = Path(project_path) / ".kin_worktrees"
worktree_path = worktrees_dir / branch_name
try:
worktrees_dir.mkdir(exist_ok=True)
r = subprocess.run(
[git, "worktree", "add", "-b", branch_name, str(worktree_path), "HEAD"],
cwd=project_path,
capture_output=True,
text=True,
timeout=30,
)
if r.returncode != 0:
_logger.warning("git worktree add failed for %s: %s", branch_name, r.stderr.strip())
return None
_logger.info("Created worktree: %s", worktree_path)
return str(worktree_path)
except Exception as exc:
_logger.warning("create_worktree error for %s: %s", branch_name, exc)
return None
def merge_worktree(worktree_path: str, project_path: str) -> dict:
"""Merge the worktree branch back into current HEAD of project_path.
Branch name is derived from the worktree directory name.
On conflict: aborts merge and returns success=False with conflict list.
Returns {success: bool, conflicts: list[str], merged_files: list[str]}
"""
git = _git(project_path)
branch_name = Path(worktree_path).name
try:
merge_result = subprocess.run(
[git, "-C", project_path, "merge", "--no-ff", branch_name],
capture_output=True,
text=True,
timeout=60,
)
if merge_result.returncode == 0:
diff_result = subprocess.run(
[git, "-C", project_path, "diff", "HEAD~1", "HEAD", "--name-only"],
capture_output=True,
text=True,
timeout=10,
)
merged_files = [
f.strip() for f in diff_result.stdout.splitlines() if f.strip()
]
_logger.info("Merged worktree %s: %d files", branch_name, len(merged_files))
return {"success": True, "conflicts": [], "merged_files": merged_files}
# Merge failed — collect conflicts and abort
conflict_result = subprocess.run(
[git, "-C", project_path, "diff", "--name-only", "--diff-filter=U"],
capture_output=True,
text=True,
timeout=10,
)
conflicts = [f.strip() for f in conflict_result.stdout.splitlines() if f.strip()]
subprocess.run(
[git, "-C", project_path, "merge", "--abort"],
capture_output=True,
timeout=10,
)
_logger.warning("Merge conflict in worktree %s: %s", branch_name, conflicts)
return {"success": False, "conflicts": conflicts, "merged_files": []}
except Exception as exc:
_logger.warning("merge_worktree error for %s: %s", branch_name, exc)
return {"success": False, "conflicts": [], "merged_files": [], "error": str(exc)}
def cleanup_worktree(worktree_path: str, project_path: str) -> None:
"""Remove the git worktree and its branch. Never raises."""
git = _git(project_path)
branch_name = Path(worktree_path).name
try:
subprocess.run(
[git, "-C", project_path, "worktree", "remove", "--force", worktree_path],
capture_output=True,
timeout=30,
)
subprocess.run(
[git, "-C", project_path, "branch", "-D", branch_name],
capture_output=True,
timeout=10,
)
_logger.info("Cleaned up worktree: %s", worktree_path)
except Exception as exc:
_logger.warning("cleanup_worktree error for %s: %s", branch_name, exc)
def ensure_gitignore(project_path: str) -> None:
"""Ensure .kin_worktrees/ is in project's .gitignore. Never raises."""
entry = ".kin_worktrees/"
gitignore = Path(project_path) / ".gitignore"
try:
if gitignore.exists():
content = gitignore.read_text()
if entry not in content:
with gitignore.open("a") as f:
f.write(f"\n{entry}\n")
else:
gitignore.write_text(f"{entry}\n")
except Exception as exc:
_logger.warning("ensure_gitignore error: %s", exc)

View file

@ -448,11 +448,12 @@ class TestAttachmentsInContext:
assert "mockup.jpg" in filenames
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths
def test_build_context_no_attachments_key_when_empty(self, conn):
"""KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет."""
def test_build_context_attachments_key_always_present(self, conn):
"""KIN-094 #213: ключ 'attachments' всегда присутствует в контексте (пустой список если нет вложений)."""
# conn fixture has no attachments
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
assert "attachments" not in ctx
assert "attachments" in ctx
assert ctx["attachments"] == []
def test_all_roles_get_attachments(self, conn_with_attachments):
"""KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения."""
@ -473,3 +474,193 @@ class TestAttachmentsInContext:
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
prompt = format_prompt(ctx, "debugger", "Debug this.")
assert "## Attachments" not in prompt
# ---------------------------------------------------------------------------
# KIN-094: Attachments — ctx["attachments"] always present + inline text content
# ---------------------------------------------------------------------------
class TestAttachmentsKIN094:
"""KIN-094: AC3 — PM и другие агенты всегда получают ключ attachments в контексте;
текстовые файлы <= 32 KB вставляются inline в промпт."""
@pytest.fixture
def conn_no_attachments(self):
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", "/tmp/prj")
models.create_task(c, "PRJ-001", "prj", "Task")
yield c
c.close()
@pytest.fixture
def conn_text_attachment(self, tmp_path):
"""Проект с текстовым вложением <= 32 KB на диске."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
txt_file = tmp_path / "spec.txt"
txt_file.write_text("Привет, это спека задачи", encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "spec.txt", str(txt_file), "text/plain", txt_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_md_attachment(self, tmp_path):
"""Проект с .md вложением (text/markdown или определяется по расширению)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
md_file = tmp_path / "README.md"
md_file.write_text("# Title\n\nContent of readme", encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "README.md", str(md_file), "text/markdown", md_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_json_attachment(self, tmp_path):
"""Проект с JSON-вложением (application/json)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
json_file = tmp_path / "config.json"
json_file.write_text('{"key": "value"}', encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "config.json", str(json_file), "application/json", json_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_large_text_attachment(self, tmp_path):
"""Проект с текстовым вложением > 32 KB (не должно инлайниться)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
big_file = tmp_path / "big.txt"
big_file.write_text("x" * (32 * 1024 + 1), encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "big.txt", str(big_file), "text/plain", big_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_image_attachment(self, tmp_path):
"""Проект с бинарным PNG-вложением (не должно инлайниться)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
png_file = tmp_path / "screen.png"
png_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
models.create_attachment(
c, "PRJ-001", "screen.png", str(png_file), "image/png", png_file.stat().st_size,
)
yield c
c.close()
# ------------------------------------------------------------------
# ctx["attachments"] always present
# ------------------------------------------------------------------
def test_pm_context_attachments_empty_list_when_no_attachments(self, conn_no_attachments):
"""KIN-094: PM получает пустой список attachments, а не отсутствующий ключ."""
ctx = build_context(conn_no_attachments, "PRJ-001", "pm", "prj")
assert "attachments" in ctx
assert ctx["attachments"] == []
def test_all_roles_attachments_key_present_when_empty(self, conn_no_attachments):
"""KIN-094: все роли получают ключ attachments (пустой список) даже без вложений."""
for role in ("pm", "debugger", "tester", "reviewer", "backend_dev", "frontend_dev", "architect"):
ctx = build_context(conn_no_attachments, "PRJ-001", role, "prj")
assert "attachments" in ctx, f"Role '{role}' missing 'attachments' key"
assert isinstance(ctx["attachments"], list), f"Role '{role}': attachments is not a list"
# ------------------------------------------------------------------
# Inline content for small text files
# ------------------------------------------------------------------
def test_format_prompt_inlines_small_text_file_content(self, conn_text_attachment):
"""KIN-094: содержимое текстового файла <= 32 KB вставляется inline в промпт."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "Привет, это спека задачи" in prompt
def test_format_prompt_inlines_text_file_in_code_block(self, conn_text_attachment):
"""KIN-094: inline-контент обёрнут в блок кода (``` ... ```)."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "```" in prompt
def test_format_prompt_inlines_md_file_by_extension(self, conn_md_attachment):
"""KIN-094: .md файл определяется по расширению и вставляется inline."""
ctx = build_context(conn_md_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "# Title" in prompt
assert "Content of readme" in prompt
def test_format_prompt_inlines_json_file_by_mime(self, conn_json_attachment):
"""KIN-094: application/json файл вставляется inline по MIME-типу."""
ctx = build_context(conn_json_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert '"key": "value"' in prompt
# ------------------------------------------------------------------
# NOT inlined: binary and large files
# ------------------------------------------------------------------
def test_format_prompt_does_not_inline_image_file(self, conn_image_attachment):
"""KIN-094: бинарный PNG файл НЕ вставляется inline."""
ctx = build_context(conn_image_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
# File is listed in ## Attachments section but no ``` block with binary content
assert "screen.png" in prompt # listed
assert "image/png" in prompt
# Should not contain raw binary or ``` code block for the PNG
# We verify the file content (PNG header) is NOT inlined
assert "\x89PNG" not in prompt
def test_format_prompt_does_not_inline_large_text_file(self, conn_large_text_attachment):
"""KIN-094: текстовый файл > 32 KB НЕ вставляется inline."""
ctx = build_context(conn_large_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "big.txt" in prompt # listed
# Content should NOT be inlined (32KB+1 of 'x' chars)
assert "x" * 100 not in prompt
# ------------------------------------------------------------------
# Resilience: missing file on disk
# ------------------------------------------------------------------
def test_format_prompt_handles_missing_file_gracefully(self, tmp_path):
"""KIN-094: если файл отсутствует на диске, format_prompt не падает."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
# Register attachment pointing to non-existent file
models.create_attachment(
c, "PRJ-001", "missing.txt",
str(tmp_path / "missing.txt"),
"text/plain", 100,
)
ctx = build_context(c, "PRJ-001", "pm", "prj")
# Should not raise — exception is caught silently
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "missing.txt" in prompt # still listed
c.close()
# ------------------------------------------------------------------
# PM pipeline: attachments available in brief context
# ------------------------------------------------------------------
def test_pm_context_includes_attachment_paths_for_pipeline(self, conn_text_attachment):
"""KIN-094: PM-агент получает пути к вложениям в контексте для старта pipeline."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
assert len(ctx["attachments"]) == 1
att = ctx["attachments"][0]
assert att["filename"] == "spec.txt"
assert att["mime_type"] == "text/plain"
assert "path" in att

View file

@ -0,0 +1,551 @@
"""
Regression tests for KIN-091:
(1) Revise button feedback loop, revise_count, target_role, max limit
(2) Auto-test before review _run_project_tests, fix loop, block on exhaustion
(3) Spec-driven workflow route exists and has correct steps in specialists.yaml
(4) Git worktrees create/merge/cleanup/ensure_gitignore with mocked subprocess
(5) Auto-trigger pipeline task with label 'auto' triggers pipeline on creation
"""
import json
import subprocess
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock, call
import web.api as api_module
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def client(tmp_path):
db_path = tmp_path / "test.db"
api_module.DB_PATH = db_path
from web.api import app
from fastapi.testclient import TestClient
c = TestClient(app)
c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/tmp/p1"})
c.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"})
return c
@pytest.fixture
def conn():
from core.db import init_db
from core import models
c = init_db(":memory:")
models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek",
tech_stack=["vue3"])
models.create_task(c, "VDOL-001", "vdol", "Fix bug",
brief={"route_type": "debug"})
yield c
c.close()
# ---------------------------------------------------------------------------
# (1) Revise button — revise_count, target_role, max limit
# ---------------------------------------------------------------------------
class TestReviseEndpoint:
def test_revise_increments_revise_count(self, client):
"""revise_count начинается с 0 и увеличивается на 1 при каждом вызове."""
r = client.post("/api/tasks/P1-001/revise", json={"comment": "ещё раз"})
assert r.status_code == 200
assert r.json()["revise_count"] == 1
r = client.post("/api/tasks/P1-001/revise", json={"comment": "и ещё"})
assert r.status_code == 200
assert r.json()["revise_count"] == 2
def test_revise_stores_target_role(self, client):
"""target_role сохраняется в задаче в БД."""
from core.db import init_db
from core import models
r = client.post("/api/tasks/P1-001/revise", json={
"comment": "доработай бэкенд",
"target_role": "backend_dev",
})
assert r.status_code == 200
conn = init_db(api_module.DB_PATH)
row = conn.execute(
"SELECT revise_target_role FROM tasks WHERE id = 'P1-001'"
).fetchone()
conn.close()
assert row["revise_target_role"] == "backend_dev"
def test_revise_target_role_builds_short_steps(self, client):
"""Если передан target_role, pipeline_steps = [target_role, reviewer]."""
r = client.post("/api/tasks/P1-001/revise", json={
"comment": "фикс",
"target_role": "frontend_dev",
})
assert r.status_code == 200
steps = r.json()["pipeline_steps"]
roles = [s["role"] for s in steps]
assert roles == ["frontend_dev", "reviewer"]
def test_revise_max_count_exceeded_returns_400(self, client):
"""После 5 ревизий следующий вызов возвращает 400."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", revise_count=5)
conn.close()
r = client.post("/api/tasks/P1-001/revise", json={"comment": "6-й"})
assert r.status_code == 400
assert "Max revisions" in r.json()["detail"]
def test_revise_sets_status_in_progress(self, client):
"""После /revise задача переходит в статус in_progress."""
r = client.post("/api/tasks/P1-001/revise", json={"comment": "исправь"})
assert r.status_code == 200
assert r.json()["status"] == "in_progress"
def test_revise_only_visible_for_review_done_tasks(self, client):
"""Задача со статусом 'review' возвращает 200, а не 404."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="review")
conn.close()
r = client.post("/api/tasks/P1-001/revise", json={"comment": "review→revise"})
assert r.status_code == 200
def test_revise_done_task_allowed(self, client):
"""Задача со статусом 'done' тоже может быть ревизована."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="done")
conn.close()
r = client.post("/api/tasks/P1-001/revise", json={"comment": "done→revise"})
assert r.status_code == 200
assert r.json()["status"] == "in_progress"
# ---------------------------------------------------------------------------
# (2) Auto-test before review — _run_project_tests, fix loop, block
# ---------------------------------------------------------------------------
class TestRunProjectTests:
def test_returns_success_when_make_exits_0(self):
"""_run_project_tests возвращает success=True при returncode=0."""
from agents.runner import _run_project_tests
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "All tests passed."
mock_result.stderr = ""
with patch("agents.runner.subprocess.run", return_value=mock_result):
result = _run_project_tests("/fake/path")
assert result["success"] is True
assert "All tests passed." in result["output"]
def test_returns_failure_when_make_exits_nonzero(self):
"""_run_project_tests возвращает success=False при returncode!=0."""
from agents.runner import _run_project_tests
mock_result = MagicMock()
mock_result.returncode = 2
mock_result.stdout = ""
mock_result.stderr = "FAILED 3 tests"
with patch("agents.runner.subprocess.run", return_value=mock_result):
result = _run_project_tests("/fake/path")
assert result["success"] is False
assert "FAILED" in result["output"]
def test_handles_make_not_found(self):
"""_run_project_tests возвращает success=False если make не найден."""
from agents.runner import _run_project_tests
with patch("agents.runner.subprocess.run", side_effect=FileNotFoundError):
result = _run_project_tests("/fake/path")
assert result["success"] is False
assert result["returncode"] == 127
def test_handles_timeout(self):
"""_run_project_tests возвращает success=False при таймауте."""
from agents.runner import _run_project_tests
with patch("agents.runner.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="make", timeout=120)):
result = _run_project_tests("/fake/path", timeout=120)
assert result["success"] is False
assert result["returncode"] == 124
def _mock_success(output="done"):
m = MagicMock()
m.stdout = json.dumps({"result": output})
m.stderr = ""
m.returncode = 0
return m
def _mock_failure(msg="error"):
m = MagicMock()
m.stdout = ""
m.stderr = msg
m.returncode = 1
return m
class TestAutoTestInPipeline:
"""Pipeline с auto_test_enabled: тесты запускаются автоматически после dev-шага."""
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_auto_test_passes_pipeline_continues(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""Если авто-тест проходит — pipeline завершается успешно."""
from agents.runner import run_pipeline
from core import models
mock_run.return_value = _mock_success()
mock_tests.return_value = {"success": True, "output": "OK", "returncode": 0}
models.update_project(conn, "vdol", auto_test_enabled=True)
steps = [{"role": "backend_dev", "brief": "implement"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_tests.assert_called_once()
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_auto_test_disabled_not_called(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""Если auto_test_enabled=False — make test не вызывается."""
from agents.runner import run_pipeline
from core import models
mock_run.return_value = _mock_success()
# auto_test_enabled по умолчанию 0
steps = [{"role": "backend_dev", "brief": "implement"}]
run_pipeline(conn, "VDOL-001", steps)
mock_tests.assert_not_called()
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_auto_test_fail_triggers_fix_loop(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""Если авто-тест падает — запускается fixer агент и тесты перезапускаются."""
from agents.runner import run_pipeline
from core import models
import os
mock_run.return_value = _mock_success()
# First test call fails, second passes
mock_tests.side_effect = [
{"success": False, "output": "FAILED: test_foo", "returncode": 1},
{"success": True, "output": "OK", "returncode": 0},
]
models.update_project(conn, "vdol", auto_test_enabled=True)
with patch.dict(os.environ, {"KIN_AUTO_TEST_MAX_ATTEMPTS": "3"}):
steps = [{"role": "backend_dev", "brief": "implement"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
# _run_project_tests called twice: initial check + after fix
assert mock_tests.call_count == 2
# subprocess.run called at least twice: backend_dev + fixer backend_dev
assert mock_run.call_count >= 2
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_auto_test_exhausted_blocks_task(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""Если авто-тест падает max_attempts раз — задача блокируется."""
from agents.runner import run_pipeline
from core import models
import os
mock_run.return_value = _mock_success()
# Тест всегда падает
mock_tests.return_value = {"success": False, "output": "FAILED", "returncode": 1}
models.update_project(conn, "vdol", auto_test_enabled=True)
with patch.dict(os.environ, {"KIN_AUTO_TEST_MAX_ATTEMPTS": "2"}):
steps = [{"role": "backend_dev", "brief": "implement"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
assert "Auto-test" in (task.get("blocked_reason") or "")
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_auto_test_not_triggered_for_non_dev_roles(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""auto_test запускается только для backend_dev/frontend_dev, не для debugger."""
from agents.runner import run_pipeline
from core import models
mock_run.return_value = _mock_success()
models.update_project(conn, "vdol", auto_test_enabled=True)
steps = [{"role": "debugger", "brief": "find"}]
run_pipeline(conn, "VDOL-001", steps)
mock_tests.assert_not_called()
# ---------------------------------------------------------------------------
# (3) Spec-driven workflow route
# ---------------------------------------------------------------------------
class TestSpecDrivenRoute:
def _load_specialists(self):
import yaml
spec_path = Path(__file__).parent.parent / "agents" / "specialists.yaml"
with open(spec_path) as f:
return yaml.safe_load(f)
def test_spec_driven_route_exists(self):
"""Маршрут spec_driven должен быть объявлен в specialists.yaml."""
data = self._load_specialists()
assert "spec_driven" in data.get("routes", {})
def test_spec_driven_route_steps_order(self):
"""spec_driven route: шаги [constitution, spec, architect, task_decomposer]."""
data = self._load_specialists()
steps = data["routes"]["spec_driven"]["steps"]
assert steps == ["constitution", "spec", "architect", "task_decomposer"]
def test_spec_driven_all_roles_exist(self):
"""Все роли в spec_driven route должны быть объявлены в specialists."""
data = self._load_specialists()
specialists = data.get("specialists", {})
for role in data["routes"]["spec_driven"]["steps"]:
assert role in specialists, f"Role '{role}' missing from specialists"
def test_constitution_role_has_output_schema(self):
"""constitution должен иметь output_schema (principles, constraints, goals)."""
data = self._load_specialists()
schema = data["specialists"]["constitution"].get("output_schema", {})
assert "principles" in schema
assert "constraints" in schema
assert "goals" in schema
def test_spec_role_has_output_schema(self):
"""spec должен иметь output_schema (overview, features, api_contracts)."""
data = self._load_specialists()
schema = data["specialists"]["spec"].get("output_schema", {})
assert "overview" in schema
assert "features" in schema
assert "api_contracts" in schema
# ---------------------------------------------------------------------------
# (4) Git worktrees — create / merge / cleanup / ensure_gitignore
# ---------------------------------------------------------------------------
class TestCreateWorktree:
def test_create_worktree_success(self, tmp_path):
"""create_worktree возвращает путь при успешном git worktree add."""
from core.worktree import create_worktree
mock_r = MagicMock()
mock_r.returncode = 0
mock_r.stderr = ""
with patch("core.worktree.subprocess.run", return_value=mock_r):
path = create_worktree(str(tmp_path), "TASK-001", "backend_dev")
assert path is not None
assert "TASK-001-backend_dev" in path
def test_create_worktree_git_failure_returns_none(self, tmp_path):
"""create_worktree возвращает None если git worktree add провалился."""
from core.worktree import create_worktree
mock_r = MagicMock()
mock_r.returncode = 128
mock_r.stderr = "fatal: branch already exists"
with patch("core.worktree.subprocess.run", return_value=mock_r):
path = create_worktree(str(tmp_path), "TASK-001", "backend_dev")
assert path is None
def test_create_worktree_exception_returns_none(self, tmp_path):
"""create_worktree возвращает None при неожиданном исключении (не поднимает)."""
from core.worktree import create_worktree
with patch("core.worktree.subprocess.run", side_effect=OSError("no git")):
path = create_worktree(str(tmp_path), "TASK-001", "backend_dev")
assert path is None
def test_create_worktree_branch_name_sanitized(self, tmp_path):
"""Слэши и пробелы в имени шага заменяются на _."""
from core.worktree import create_worktree
mock_r = MagicMock()
mock_r.returncode = 0
mock_r.stderr = ""
calls_made = []
def capture(*args, **kwargs):
calls_made.append(args[0])
return mock_r
with patch("core.worktree.subprocess.run", side_effect=capture):
create_worktree(str(tmp_path), "TASK-001", "step/with spaces")
assert calls_made
cmd = calls_made[0]
branch = cmd[cmd.index("-b") + 1]
assert "/" not in branch
assert " " not in branch
class TestMergeWorktree:
def test_merge_success_returns_merged_files(self, tmp_path):
"""merge_worktree возвращает success=True и список файлов при успешном merge."""
from core.worktree import merge_worktree
worktree = str(tmp_path / "TASK-001-backend_dev")
merge_ok = MagicMock(returncode=0, stdout="", stderr="")
diff_ok = MagicMock(returncode=0, stdout="src/api.py\nsrc/models.py\n", stderr="")
with patch("core.worktree.subprocess.run", side_effect=[merge_ok, diff_ok]):
result = merge_worktree(worktree, str(tmp_path))
assert result["success"] is True
assert "src/api.py" in result["merged_files"]
assert result["conflicts"] == []
def test_merge_conflict_returns_conflict_list(self, tmp_path):
"""merge_worktree возвращает success=False и список конфликтных файлов."""
from core.worktree import merge_worktree
worktree = str(tmp_path / "TASK-001-backend_dev")
merge_fail = MagicMock(returncode=1, stdout="", stderr="CONFLICT")
conflict_files = MagicMock(returncode=0, stdout="src/models.py\n", stderr="")
abort = MagicMock(returncode=0)
with patch("core.worktree.subprocess.run",
side_effect=[merge_fail, conflict_files, abort]):
result = merge_worktree(worktree, str(tmp_path))
assert result["success"] is False
assert "src/models.py" in result["conflicts"]
def test_merge_exception_returns_success_false(self, tmp_path):
"""merge_worktree никогда не поднимает исключение."""
from core.worktree import merge_worktree
with patch("core.worktree.subprocess.run", side_effect=OSError("git died")):
result = merge_worktree("/fake/wt", str(tmp_path))
assert result["success"] is False
assert "error" in result
class TestCleanupWorktree:
def test_cleanup_calls_worktree_remove_and_branch_delete(self, tmp_path):
"""cleanup_worktree вызывает git worktree remove и git branch -D."""
from core.worktree import cleanup_worktree
calls = []
def capture(*args, **kwargs):
calls.append(args[0])
return MagicMock(returncode=0)
with patch("core.worktree.subprocess.run", side_effect=capture):
cleanup_worktree("/fake/path/TASK-branch", str(tmp_path))
assert len(calls) == 2
# первый: worktree remove
assert "worktree" in calls[0]
assert "remove" in calls[0]
# второй: branch -D
assert "branch" in calls[1]
assert "-D" in calls[1]
def test_cleanup_never_raises(self, tmp_path):
"""cleanup_worktree не поднимает исключение при ошибке."""
from core.worktree import cleanup_worktree
with patch("core.worktree.subprocess.run", side_effect=OSError("crashed")):
cleanup_worktree("/fake/wt", str(tmp_path)) # должно пройти тихо
class TestEnsureGitignore:
def test_adds_entry_to_existing_gitignore(self, tmp_path):
"""ensure_gitignore добавляет .kin_worktrees/ в существующий .gitignore."""
from core.worktree import ensure_gitignore
gi = tmp_path / ".gitignore"
gi.write_text("*.pyc\n__pycache__/\n")
ensure_gitignore(str(tmp_path))
assert ".kin_worktrees/" in gi.read_text()
def test_creates_gitignore_if_missing(self, tmp_path):
"""ensure_gitignore создаёт .gitignore если его нет."""
from core.worktree import ensure_gitignore
ensure_gitignore(str(tmp_path))
gi = tmp_path / ".gitignore"
assert gi.exists()
assert ".kin_worktrees/" in gi.read_text()
def test_skips_if_entry_already_present(self, tmp_path):
"""ensure_gitignore не дублирует запись."""
from core.worktree import ensure_gitignore
gi = tmp_path / ".gitignore"
gi.write_text(".kin_worktrees/\n")
ensure_gitignore(str(tmp_path))
content = gi.read_text()
assert content.count(".kin_worktrees/") == 1
def test_never_raises_on_permission_error(self, tmp_path):
"""ensure_gitignore не поднимает исключение при ошибке записи."""
from core.worktree import ensure_gitignore
with patch("core.worktree.Path.open", side_effect=PermissionError):
ensure_gitignore(str(tmp_path)) # должно пройти тихо
# ---------------------------------------------------------------------------
# (5) Auto-trigger pipeline — label 'auto'
# ---------------------------------------------------------------------------
class TestAutoTrigger:
def test_task_with_auto_label_triggers_pipeline(self, client):
"""Создание задачи с label 'auto' запускает pipeline в фоне."""
with patch("web.api._launch_pipeline_subprocess") as mock_launch:
r = client.post("/api/tasks", json={
"project_id": "p1",
"title": "Auto task",
"labels": ["auto"],
})
assert r.status_code == 200
mock_launch.assert_called_once()
called_task_id = mock_launch.call_args[0][0]
assert called_task_id.startswith("P1-")
def test_task_without_auto_label_does_not_trigger(self, client):
"""Создание задачи без label 'auto' НЕ запускает pipeline."""
with patch("web.api._launch_pipeline_subprocess") as mock_launch:
r = client.post("/api/tasks", json={
"project_id": "p1",
"title": "Manual task",
"labels": ["feature"],
})
assert r.status_code == 200
mock_launch.assert_not_called()
def test_task_without_labels_does_not_trigger(self, client):
"""Создание задачи без labels вообще НЕ запускает pipeline."""
with patch("web.api._launch_pipeline_subprocess") as mock_launch:
r = client.post("/api/tasks", json={
"project_id": "p1",
"title": "Plain task",
})
assert r.status_code == 200
mock_launch.assert_not_called()
def test_task_with_auto_among_multiple_labels_triggers(self, client):
"""Задача с несколькими метками включая 'auto' запускает pipeline."""
with patch("web.api._launch_pipeline_subprocess") as mock_launch:
r = client.post("/api/tasks", json={
"project_id": "p1",
"title": "Multi-label auto task",
"labels": ["feature", "auto", "backend"],
})
assert r.status_code == 200
mock_launch.assert_called_once()

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

View file

@ -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
}

View file

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