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
37
agents/prompts/constitution.md
Normal file
37
agents/prompts/constitution.md
Normal 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
45
agents/prompts/spec.md
Normal 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
|
||||||
43
agents/prompts/task_decomposer.md
Normal file
43
agents/prompts/task_decomposer.md
Normal 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
|
||||||
353
agents/runner.py
353
agents/runner.py
|
|
@ -54,6 +54,19 @@ def _build_claude_env() -> dict:
|
||||||
seen.add(d)
|
seen.add(d)
|
||||||
deduped.append(d)
|
deduped.append(d)
|
||||||
env["PATH"] = ":".join(deduped)
|
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
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -127,6 +140,7 @@ def run_agent(
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
allow_write: bool = False,
|
allow_write: bool = False,
|
||||||
noninteractive: bool = False,
|
noninteractive: bool = False,
|
||||||
|
working_dir_override: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run a single Claude Code agent as a subprocess.
|
"""Run a single Claude Code agent as a subprocess.
|
||||||
|
|
||||||
|
|
@ -161,7 +175,9 @@ def run_agent(
|
||||||
working_dir = None
|
working_dir = None
|
||||||
# Operations projects have no local path — sysadmin works via SSH
|
# Operations projects have no local path — sysadmin works via SSH
|
||||||
is_operations = project and project.get("project_type") == "operations"
|
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()
|
project_path = Path(project["path"]).expanduser()
|
||||||
if project_path.is_dir():
|
if project_path.is_dir():
|
||||||
working_dir = str(project_path)
|
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
|
# Auto-learning: extract decisions from pipeline results
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -866,6 +1027,26 @@ def run_pipeline(
|
||||||
model = step.get("model", "sonnet")
|
model = step.get("model", "sonnet")
|
||||||
brief = step.get("brief")
|
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:
|
try:
|
||||||
result = run_agent(
|
result = run_agent(
|
||||||
conn, role, task_id, project_id,
|
conn, role, task_id, project_id,
|
||||||
|
|
@ -875,6 +1056,7 @@ def run_pipeline(
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
allow_write=allow_write,
|
allow_write=allow_write,
|
||||||
noninteractive=noninteractive,
|
noninteractive=noninteractive,
|
||||||
|
working_dir_override=worktree_path,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {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,
|
"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)
|
results.append(result)
|
||||||
|
|
||||||
# Semantic blocked: agent ran successfully but returned status='blocked'
|
# Semantic blocked: agent ran successfully but returned status='blocked'
|
||||||
|
|
@ -1056,6 +1276,137 @@ def run_pipeline(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never block pipeline on sysadmin save errors
|
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
|
# Chain output to next step
|
||||||
previous_output = result.get("raw_output") or result.get("output")
|
previous_output = result.get("raw_output") or result.get("output")
|
||||||
if isinstance(previous_output, (dict, list)):
|
if isinstance(previous_output, (dict, list)):
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,46 @@ specialists:
|
||||||
codebase_diff: "array of { file, line_hint, issue, suggestion }"
|
codebase_diff: "array of { file, line_hint, issue, suggestion }"
|
||||||
notes: string
|
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
|
# Route templates — PM uses these to build pipelines
|
||||||
routes:
|
routes:
|
||||||
debug:
|
debug:
|
||||||
|
|
@ -144,3 +184,7 @@ routes:
|
||||||
infra_debug:
|
infra_debug:
|
||||||
steps: [sysadmin, debugger, reviewer]
|
steps: [sysadmin, debugger, reviewer]
|
||||||
description: "SSH diagnose → find root cause → verify fix plan"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ def build_context(
|
||||||
}
|
}
|
||||||
|
|
||||||
# Attachments — all roles get them so debugger sees screenshots, UX sees mockups, etc.
|
# 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)
|
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 has a revise comment, fetch the last agent output for context
|
||||||
|
|
@ -97,6 +97,15 @@ def build_context(
|
||||||
# Minimal context — just the task spec
|
# Minimal context — just the task spec
|
||||||
pass
|
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":
|
elif role == "security":
|
||||||
ctx["decisions"] = models.get_decisions(
|
ctx["decisions"] = models.get_decisions(
|
||||||
conn, project_id, category="security",
|
conn, project_id, category="security",
|
||||||
|
|
@ -279,7 +288,22 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None)
|
||||||
if attachments:
|
if attachments:
|
||||||
sections.append(f"## Attachments ({len(attachments)}):")
|
sections.append(f"## Attachments ({len(attachments)}):")
|
||||||
for a in 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("")
|
sections.append("")
|
||||||
|
|
||||||
# Previous step output (pipeline chaining)
|
# Previous step output (pipeline chaining)
|
||||||
|
|
|
||||||
25
core/db.py
25
core/db.py
|
|
@ -31,6 +31,8 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||||
description TEXT,
|
description TEXT,
|
||||||
autocommit_enabled INTEGER DEFAULT 0,
|
autocommit_enabled INTEGER DEFAULT 0,
|
||||||
obsidian_vault_path TEXT,
|
obsidian_vault_path TEXT,
|
||||||
|
worktrees_enabled INTEGER DEFAULT 0,
|
||||||
|
auto_test_enabled INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -56,6 +58,9 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||||
blocked_pipeline_step TEXT,
|
blocked_pipeline_step TEXT,
|
||||||
dangerously_skipped BOOLEAN DEFAULT 0,
|
dangerously_skipped BOOLEAN DEFAULT 0,
|
||||||
revise_comment TEXT,
|
revise_comment TEXT,
|
||||||
|
revise_count INTEGER DEFAULT 0,
|
||||||
|
revise_target_role TEXT DEFAULT NULL,
|
||||||
|
labels JSON,
|
||||||
category TEXT DEFAULT NULL,
|
category TEXT DEFAULT NULL,
|
||||||
telegram_sent BOOLEAN DEFAULT 0,
|
telegram_sent BOOLEAN DEFAULT 0,
|
||||||
acceptance_criteria TEXT,
|
acceptance_criteria TEXT,
|
||||||
|
|
@ -341,10 +346,30 @@ def _migrate(conn: sqlite3.Connection):
|
||||||
conn.execute("ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT")
|
conn.execute("ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT")
|
||||||
conn.commit()
|
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:
|
if "obsidian_vault_path" not in proj_cols:
|
||||||
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
|
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
|
||||||
conn.commit()
|
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:
|
if "deploy_command" not in proj_cols:
|
||||||
conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT")
|
conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -210,16 +210,18 @@ def create_task(
|
||||||
execution_mode: str | None = None,
|
execution_mode: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
acceptance_criteria: str | None = None,
|
acceptance_criteria: str | None = None,
|
||||||
|
labels: list | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a task linked to a project."""
|
"""Create a task linked to a project."""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO tasks (id, project_id, title, status, priority,
|
"""INSERT INTO tasks (id, project_id, title, status, priority,
|
||||||
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
|
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
|
||||||
execution_mode, category, acceptance_criteria)
|
execution_mode, category, acceptance_criteria, labels)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(id, project_id, title, status, priority, assigned_role,
|
(id, project_id, title, status, priority, assigned_role,
|
||||||
parent_task_id, _json_encode(brief), _json_encode(spec),
|
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()
|
conn.commit()
|
||||||
return get_task(conn, id)
|
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."""
|
"""Update task fields. Auto-sets updated_at."""
|
||||||
if not fields:
|
if not fields:
|
||||||
return get_task(conn, id)
|
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:
|
for key in json_cols:
|
||||||
if key in fields:
|
if key in fields:
|
||||||
fields[key] = _json_encode(fields[key])
|
fields[key] = _json_encode(fields[key])
|
||||||
|
|
|
||||||
149
core/worktree.py
Normal file
149
core/worktree.py
Normal 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)
|
||||||
|
|
@ -448,11 +448,12 @@ class TestAttachmentsInContext:
|
||||||
assert "mockup.jpg" in filenames
|
assert "mockup.jpg" in filenames
|
||||||
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths
|
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths
|
||||||
|
|
||||||
def test_build_context_no_attachments_key_when_empty(self, conn):
|
def test_build_context_attachments_key_always_present(self, conn):
|
||||||
"""KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет."""
|
"""KIN-094 #213: ключ 'attachments' всегда присутствует в контексте (пустой список если нет вложений)."""
|
||||||
# conn fixture has no attachments
|
# conn fixture has no attachments
|
||||||
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
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):
|
def test_all_roles_get_attachments(self, conn_with_attachments):
|
||||||
"""KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения."""
|
"""KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения."""
|
||||||
|
|
@ -473,3 +474,193 @@ class TestAttachmentsInContext:
|
||||||
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
||||||
prompt = format_prompt(ctx, "debugger", "Debug this.")
|
prompt = format_prompt(ctx, "debugger", "Debug this.")
|
||||||
assert "## Attachments" not in prompt
|
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
|
||||||
|
|
|
||||||
551
tests/test_kin_091_regression.py
Normal file
551
tests/test_kin_091_regression.py
Normal 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()
|
||||||
90
web/api.py
90
web/api.py
|
|
@ -99,6 +99,32 @@ def get_conn():
|
||||||
return init_db(DB_PATH)
|
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
|
# Projects
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -193,6 +219,7 @@ class ProjectCreate(BaseModel):
|
||||||
class ProjectPatch(BaseModel):
|
class ProjectPatch(BaseModel):
|
||||||
execution_mode: str | None = None
|
execution_mode: str | None = None
|
||||||
autocommit_enabled: bool | None = None
|
autocommit_enabled: bool | None = None
|
||||||
|
auto_test_enabled: bool | None = None
|
||||||
obsidian_vault_path: str | None = None
|
obsidian_vault_path: str | None = None
|
||||||
deploy_command: str | None = None
|
deploy_command: str | None = None
|
||||||
project_type: str | None = None
|
project_type: str | None = None
|
||||||
|
|
@ -206,6 +233,7 @@ class ProjectPatch(BaseModel):
|
||||||
def patch_project(project_id: str, body: ProjectPatch):
|
def patch_project(project_id: str, body: ProjectPatch):
|
||||||
has_any = any([
|
has_any = any([
|
||||||
body.execution_mode, body.autocommit_enabled is not None,
|
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.obsidian_vault_path, body.deploy_command is not None,
|
||||||
body.project_type, body.ssh_host 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,
|
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
|
fields["execution_mode"] = body.execution_mode
|
||||||
if body.autocommit_enabled is not None:
|
if body.autocommit_enabled is not None:
|
||||||
fields["autocommit_enabled"] = int(body.autocommit_enabled)
|
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:
|
if body.obsidian_vault_path is not None:
|
||||||
fields["obsidian_vault_path"] = body.obsidian_vault_path
|
fields["obsidian_vault_path"] = body.obsidian_vault_path
|
||||||
if body.deploy_command is not None:
|
if body.deploy_command is not None:
|
||||||
|
|
@ -527,6 +557,7 @@ class TaskCreate(BaseModel):
|
||||||
route_type: str | None = None
|
route_type: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
acceptance_criteria: str | None = None
|
acceptance_criteria: str | None = None
|
||||||
|
labels: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks")
|
@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
|
brief = {"route_type": body.route_type} if body.route_type else None
|
||||||
t = models.create_task(conn, task_id, body.project_id, body.title,
|
t = models.create_task(conn, task_id, body.project_id, body.title,
|
||||||
priority=body.priority, brief=brief, category=category,
|
priority=body.priority, brief=brief, category=category,
|
||||||
acceptance_criteria=body.acceptance_criteria)
|
acceptance_criteria=body.acceptance_criteria,
|
||||||
|
labels=body.labels)
|
||||||
conn.close()
|
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
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -763,21 +800,66 @@ def reject_task(task_id: str, body: TaskReject):
|
||||||
return {"status": "pending", "reason": body.reason}
|
return {"status": "pending", "reason": body.reason}
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_REVISE_COUNT = 5
|
||||||
|
|
||||||
|
|
||||||
class TaskRevise(BaseModel):
|
class TaskRevise(BaseModel):
|
||||||
comment: str
|
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")
|
@app.post("/api/tasks/{task_id}/revise")
|
||||||
def revise_task(task_id: str, body: TaskRevise):
|
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()
|
conn = get_conn()
|
||||||
t = models.get_task(conn, task_id)
|
t = models.get_task(conn, task_id)
|
||||||
if not t:
|
if not t:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
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()
|
conn.close()
|
||||||
return {"status": "in_progress", "comment": body.comment}
|
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()
|
||||||
|
|
||||||
|
# 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")
|
@app.get("/api/tasks/{task_id}/running")
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export interface Task {
|
||||||
dangerously_skipped: number | null
|
dangerously_skipped: number | null
|
||||||
category: string | null
|
category: string | null
|
||||||
acceptance_criteria: string | null
|
acceptance_criteria: string | null
|
||||||
|
feedback?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,34 @@ const startPhaseSaving = ref(false)
|
||||||
const approvePhaseSaving = ref(false)
|
const approvePhaseSaving = ref(false)
|
||||||
let phasePollTimer: ReturnType<typeof setInterval> | null = null
|
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() {
|
function checkAndPollPhases() {
|
||||||
const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress')
|
const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress')
|
||||||
if (hasRunning && !phasePollTimer) {
|
if (hasRunning && !phasePollTimer) {
|
||||||
|
|
@ -143,7 +171,7 @@ function phaseStatusColor(s: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters
|
// 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[] {
|
function initStatusFilter(): string[] {
|
||||||
const q = route.query.status as string
|
const q = route.query.status as string
|
||||||
|
|
@ -333,6 +361,21 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||||
const showAddTask = ref(false)
|
const showAddTask = ref(false)
|
||||||
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
|
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
|
||||||
const taskFormError = ref('')
|
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
|
// Add decision modal
|
||||||
const showAddDecision = ref(false)
|
const showAddDecision = ref(false)
|
||||||
|
|
@ -421,6 +464,7 @@ function taskStatusColor(s: string) {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
pending: 'gray', in_progress: 'blue', review: 'purple',
|
pending: 'gray', in_progress: 'blue', review: 'purple',
|
||||||
done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray',
|
done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray',
|
||||||
|
revising: 'orange',
|
||||||
}
|
}
|
||||||
return m[s] || 'gray'
|
return m[s] || 'gray'
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +493,7 @@ const decTypes = computed(() => {
|
||||||
async function addTask() {
|
async function addTask() {
|
||||||
taskFormError.value = ''
|
taskFormError.value = ''
|
||||||
try {
|
try {
|
||||||
await api.createTask({
|
const task = await api.createTask({
|
||||||
project_id: props.id,
|
project_id: props.id,
|
||||||
title: taskForm.value.title,
|
title: taskForm.value.title,
|
||||||
priority: taskForm.value.priority,
|
priority: taskForm.value.priority,
|
||||||
|
|
@ -457,6 +501,20 @@ async function addTask() {
|
||||||
category: taskForm.value.category || undefined,
|
category: taskForm.value.category || undefined,
|
||||||
acceptance_criteria: taskForm.value.acceptance_criteria || 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
|
showAddTask.value = false
|
||||||
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
|
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
|
||||||
await load()
|
await load()
|
||||||
|
|
@ -798,6 +856,12 @@ async function addDecision() {
|
||||||
</button>
|
</button>
|
||||||
<span v-if="t.status === 'in_progress'"
|
<span v-if="t.status === 'in_progress'"
|
||||||
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
|
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>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1092,7 +1156,7 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Task Modal -->
|
<!-- 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">
|
<form @submit.prevent="addTask" class="space-y-3">
|
||||||
<input v-model="taskForm.title" placeholder="Task title" required
|
<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" />
|
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="Что должно быть на выходе? Какой результат считается успешным?"
|
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>
|
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>
|
||||||
|
<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>
|
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
||||||
<button type="submit"
|
<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">
|
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>
|
</form>
|
||||||
</Modal>
|
</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 -->
|
<!-- Audit Modal -->
|
||||||
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
||||||
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue