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
|
|
@ -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)
|
||||
|
|
|
|||
25
core/db.py
25
core/db.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue