kin: KIN-091 Улучшения из исследования рынка: (1) Revise button с feedback loop, (2) auto-test before review — агент сам прогоняет тесты и фиксит до review, (3) spec-driven workflow для новых проектов — constitution → spec → plan → tasks, (4) git worktrees для параллельных агентов без конфликтов, (5) auto-trigger pipeline при создании задачи с label auto

This commit is contained in:
Gros Frumos 2026-03-16 22:35:31 +02:00
parent 0cc063d47a
commit 0ccd451b4b
14 changed files with 1660 additions and 18 deletions

View file

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

View file

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

View file

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

149
core/worktree.py Normal file
View file

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