kin: KIN-048 Post-pipeline hook: автокоммит после успешного завершения задачи. git add -A && git commit -m 'kin: TASK_ID TITLE'. Срабатывает автоматически как rebuild-frontend.
This commit is contained in:
parent
8a6f280cbd
commit
ae21e48b65
13 changed files with 1554 additions and 65 deletions
252
agents/runner.py
252
agents/runner.py
|
|
@ -4,7 +4,9 @@ Each agent = separate process with isolated context.
|
|||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
|
@ -13,6 +15,50 @@ from typing import Any
|
|||
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger("kin.runner")
|
||||
|
||||
|
||||
# Extra PATH entries to inject when searching for claude CLI.
|
||||
# launchctl daemons start with a stripped PATH that may omit these.
|
||||
_EXTRA_PATH_DIRS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
]
|
||||
|
||||
|
||||
def _build_claude_env() -> dict:
|
||||
"""Return an env dict with an extended PATH that includes common CLI tool locations.
|
||||
|
||||
Merges _EXTRA_PATH_DIRS with the current process PATH, deduplicating entries.
|
||||
Also resolves ~/.nvm/versions/node/*/bin globs that launchctl may not expand.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
existing = env.get("PATH", "").split(":")
|
||||
|
||||
extra = list(_EXTRA_PATH_DIRS)
|
||||
|
||||
# Expand nvm node bin dirs dynamically
|
||||
nvm_root = Path.home() / ".nvm" / "versions" / "node"
|
||||
if nvm_root.is_dir():
|
||||
for node_ver in sorted(nvm_root.iterdir(), reverse=True):
|
||||
bin_dir = node_ver / "bin"
|
||||
if bin_dir.is_dir():
|
||||
extra.append(str(bin_dir))
|
||||
|
||||
seen = set(existing)
|
||||
new_dirs = [d for d in extra if d and d not in seen]
|
||||
env["PATH"] = ":".join(new_dirs + existing)
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_claude_cmd() -> str:
|
||||
"""Return the full path to the claude CLI, or 'claude' as fallback."""
|
||||
extended_env = _build_claude_env()
|
||||
found = shutil.which("claude", path=extended_env["PATH"])
|
||||
return found or "claude"
|
||||
|
||||
from core import models
|
||||
from core.context_builder import build_context, format_prompt
|
||||
from core.hooks import run_hooks
|
||||
|
|
@ -116,10 +162,12 @@ def _run_claude(
|
|||
working_dir: str | None = None,
|
||||
allow_write: bool = False,
|
||||
noninteractive: bool = False,
|
||||
timeout: int | None = None,
|
||||
) -> dict:
|
||||
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
||||
claude_cmd = _resolve_claude_cmd()
|
||||
cmd = [
|
||||
"claude",
|
||||
claude_cmd,
|
||||
"-p", prompt,
|
||||
"--output-format", "json",
|
||||
"--model", model,
|
||||
|
|
@ -128,7 +176,9 @@ def _run_claude(
|
|||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||
timeout = 300 if is_noninteractive else 600
|
||||
if timeout is None:
|
||||
timeout = int(os.environ.get("KIN_AGENT_TIMEOUT") or 600)
|
||||
env = _build_claude_env()
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
|
|
@ -137,6 +187,7 @@ def _run_claude(
|
|||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL if is_noninteractive else None,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
|
|
@ -377,6 +428,179 @@ def _is_permission_error(result: dict) -> bool:
|
|||
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autocommit: git add -A && git commit after successful pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_autocommit(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
) -> None:
|
||||
"""Auto-commit changes after successful pipeline completion.
|
||||
|
||||
Runs: git add -A && git commit -m 'kin: {task_id} {title}'.
|
||||
Silently skips if nothing to commit (exit code 1) or project path not found.
|
||||
Never raises — autocommit errors must never block the pipeline.
|
||||
Uses stderr=subprocess.DEVNULL per decision #30.
|
||||
"""
|
||||
task = models.get_task(conn, task_id)
|
||||
project = models.get_project(conn, project_id)
|
||||
if not task or not project:
|
||||
return
|
||||
|
||||
if not project.get("autocommit_enabled"):
|
||||
return
|
||||
|
||||
project_path = Path(project["path"]).expanduser()
|
||||
if not project_path.is_dir():
|
||||
return
|
||||
|
||||
working_dir = str(project_path)
|
||||
env = _build_claude_env()
|
||||
git_cmd = shutil.which("git", path=env["PATH"]) or "git"
|
||||
|
||||
title = (task.get("title") or "").replace('"', "'").replace("\n", " ").replace("\r", "")
|
||||
commit_msg = f"kin: {task_id} {title}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[git_cmd, "add", "-A"],
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[git_cmd, "commit", "-m", commit_msg],
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_logger.info("Autocommit: %s", commit_msg)
|
||||
else:
|
||||
_logger.debug("Autocommit: nothing to commit for %s", task_id)
|
||||
except Exception as exc:
|
||||
_logger.warning("Autocommit failed for %s: %s", task_id, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-learning: extract decisions from pipeline results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_DECISION_TYPES = {"decision", "gotcha", "convention"}
|
||||
|
||||
def _run_learning_extraction(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
step_results: list[dict],
|
||||
) -> dict:
|
||||
"""Extract and save decisions from completed pipeline results.
|
||||
|
||||
Calls the learner agent with step outputs + existing decisions,
|
||||
parses the JSON response, and saves new decisions via add_decision_if_new.
|
||||
Returns a summary dict with added/skipped counts.
|
||||
"""
|
||||
learner_prompt_path = PROMPTS_DIR / "learner.md"
|
||||
if not learner_prompt_path.exists():
|
||||
return {"added": 0, "skipped": 0, "error": "learner.md not found"}
|
||||
|
||||
template = learner_prompt_path.read_text()
|
||||
|
||||
# Summarize step outputs (first 2000 chars each)
|
||||
step_summaries = {}
|
||||
for r in step_results:
|
||||
role = r.get("role", "unknown")
|
||||
output = r.get("raw_output") or r.get("output") or ""
|
||||
if isinstance(output, (dict, list)):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
step_summaries[role] = output[:2000]
|
||||
|
||||
# Fetch existing decisions for dedup hint
|
||||
existing = models.get_decisions(conn, project_id)
|
||||
existing_hints = [
|
||||
{"title": d["title"], "type": d["type"]}
|
||||
for d in existing
|
||||
]
|
||||
|
||||
prompt_parts = [
|
||||
template,
|
||||
"",
|
||||
"## PIPELINE_OUTPUTS",
|
||||
json.dumps(step_summaries, ensure_ascii=False, indent=2),
|
||||
"",
|
||||
"## EXISTING_DECISIONS",
|
||||
json.dumps(existing_hints, ensure_ascii=False, indent=2),
|
||||
]
|
||||
prompt = "\n".join(prompt_parts)
|
||||
|
||||
learner_timeout = int(os.environ.get("KIN_LEARNER_TIMEOUT") or 120)
|
||||
start = time.monotonic()
|
||||
result = _run_claude(prompt, model="sonnet", noninteractive=True, timeout=learner_timeout)
|
||||
duration = int(time.monotonic() - start)
|
||||
|
||||
raw_output = result.get("output", "")
|
||||
if not isinstance(raw_output, str):
|
||||
raw_output = json.dumps(raw_output, ensure_ascii=False)
|
||||
success = result["returncode"] == 0
|
||||
|
||||
# Log to agent_logs
|
||||
models.log_agent_run(
|
||||
conn,
|
||||
project_id=project_id,
|
||||
task_id=task_id,
|
||||
agent_role="learner",
|
||||
action="learn",
|
||||
input_summary=f"project={project_id}, task={task_id}, steps={len(step_results)}",
|
||||
output_summary=raw_output or None,
|
||||
tokens_used=result.get("tokens_used"),
|
||||
model="sonnet",
|
||||
cost_usd=result.get("cost_usd"),
|
||||
success=success,
|
||||
error_message=result.get("error") if not success else None,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
|
||||
parsed = _try_parse_json(raw_output)
|
||||
if not isinstance(parsed, dict):
|
||||
return {"added": 0, "skipped": 0, "error": "non-JSON learner output"}
|
||||
|
||||
decisions = parsed.get("decisions", [])
|
||||
if not isinstance(decisions, list):
|
||||
return {"added": 0, "skipped": 0, "error": "invalid decisions format"}
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
for item in decisions[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
d_type = item.get("type", "decision")
|
||||
if d_type not in VALID_DECISION_TYPES:
|
||||
d_type = "decision"
|
||||
d_title = (item.get("title") or "").strip()
|
||||
d_desc = (item.get("description") or "").strip()
|
||||
if not d_title or not d_desc:
|
||||
continue
|
||||
saved = models.add_decision_if_new(
|
||||
conn,
|
||||
project_id=project_id,
|
||||
type=d_type,
|
||||
title=d_title,
|
||||
description=d_desc,
|
||||
tags=item.get("tags") or [],
|
||||
task_id=task_id,
|
||||
)
|
||||
if saved:
|
||||
added += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -485,7 +709,7 @@ def run_pipeline(
|
|||
|
||||
if not result["success"]:
|
||||
# Auto mode: retry once with allow_write on permission error
|
||||
if mode == "auto" and not allow_write and _is_permission_error(result):
|
||||
if mode == "auto_complete" and not allow_write and _is_permission_error(result):
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
|
|
@ -555,8 +779,11 @@ def run_pipeline(
|
|||
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
|
||||
if mode == "auto":
|
||||
# Auto mode: skip review, approve immediately
|
||||
last_role = steps[-1].get("role", "") if steps else ""
|
||||
auto_eligible = last_role in {"tester", "reviewer"}
|
||||
|
||||
if mode == "auto_complete" and auto_eligible:
|
||||
# Auto-complete mode: last step is tester/reviewer — skip review, approve immediately
|
||||
models.update_task(conn, task_id, status="done")
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
|
|
@ -586,7 +813,7 @@ def run_pipeline(
|
|||
pass
|
||||
else:
|
||||
# Review mode: wait for manual approval
|
||||
models.update_task(conn, task_id, status="review")
|
||||
models.update_task(conn, task_id, status="review", execution_mode="review")
|
||||
|
||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||
try:
|
||||
|
|
@ -595,6 +822,19 @@ def run_pipeline(
|
|||
except Exception:
|
||||
pass # Hook errors must never block pipeline completion
|
||||
|
||||
# Auto-learning: extract decisions from pipeline results
|
||||
if results:
|
||||
try:
|
||||
_run_learning_extraction(conn, task_id, project_id, results)
|
||||
except Exception:
|
||||
pass # Learning errors must never block pipeline completion
|
||||
|
||||
# Auto-commit changes after successful pipeline
|
||||
try:
|
||||
_run_autocommit(conn, task_id, project_id)
|
||||
except Exception:
|
||||
pass # Autocommit errors must never block pipeline completion
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps_completed": len(steps),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue