kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 15:40:31 +02:00
parent f805aff86b
commit 6c2da26b6c
6 changed files with 138 additions and 10 deletions

View file

@ -6,6 +6,7 @@ Each agent = separate process with isolated context.
import json import json
import logging import logging
import os import os
import shlex
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
@ -778,16 +779,20 @@ def _is_test_failure(result: dict) -> bool:
_AUTO_TEST_ROLES = {"backend_dev", "frontend_dev"} _AUTO_TEST_ROLES = {"backend_dev", "frontend_dev"}
def _run_project_tests(project_path: str, timeout: int = 120) -> dict: def _run_project_tests(project_path: str, test_command: str = 'make test', timeout: int = 120) -> dict:
"""Run `make test` in project_path. Returns {success, output, returncode}. """Run test_command in project_path. Returns {success, output, returncode}.
Never raises all errors are captured and returned in output. Never raises all errors are captured and returned in output.
""" """
env = _build_claude_env() env = _build_claude_env()
make_cmd = shutil.which("make", path=env["PATH"]) or "make" parts = shlex.split(test_command)
if not parts:
return {"success": False, "output": "Empty test_command", "returncode": -1}
resolved = shutil.which(parts[0], path=env["PATH"]) or parts[0]
cmd = [resolved] + parts[1:]
try: try:
result = subprocess.run( result = subprocess.run(
[make_cmd, "test"], cmd,
cwd=project_path, cwd=project_path,
capture_output=True, capture_output=True,
text=True, text=True,
@ -797,9 +802,9 @@ def _run_project_tests(project_path: str, timeout: int = 120) -> dict:
output = (result.stdout or "") + (result.stderr or "") output = (result.stdout or "") + (result.stderr or "")
return {"success": result.returncode == 0, "output": output, "returncode": result.returncode} return {"success": result.returncode == 0, "output": output, "returncode": result.returncode}
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {"success": False, "output": f"make test timed out after {timeout}s", "returncode": 124} return {"success": False, "output": f"{test_command} timed out after {timeout}s", "returncode": 124}
except FileNotFoundError: except FileNotFoundError:
return {"success": False, "output": "make not found — no Makefile or make not in PATH", "returncode": 127} return {"success": False, "output": f"{parts[0]} not found in PATH", "returncode": 127}
except Exception as exc: except Exception as exc:
return {"success": False, "output": f"Test run error: {exc}", "returncode": -1} return {"success": False, "output": f"Test run error: {exc}", "returncode": -1}
@ -1568,14 +1573,15 @@ def run_pipeline(
): ):
max_auto_test_attempts = int(os.environ.get("KIN_AUTO_TEST_MAX_ATTEMPTS") or 3) 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()) p_path_str = str(Path(project_for_wt["path"]).expanduser())
test_run = _run_project_tests(p_path_str) p_test_cmd = project_for_wt.get("test_command") or "make test"
test_run = _run_project_tests(p_path_str, p_test_cmd)
results.append({"role": "_auto_test", "success": test_run["success"], results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True}) "output": test_run["output"], "_project_test": True})
auto_test_attempt = 0 auto_test_attempt = 0
while not test_run["success"] and auto_test_attempt < max_auto_test_attempts: while not test_run["success"] and auto_test_attempt < max_auto_test_attempts:
auto_test_attempt += 1 auto_test_attempt += 1
fix_context = ( fix_context = (
f"Automated project test run (make test) failed after your changes.\n" f"Automated project test run ({p_test_cmd}) failed after your changes.\n"
f"Test output:\n{test_run['output'][:4000]}\n" f"Test output:\n{test_run['output'][:4000]}\n"
f"Fix the failing tests. Do NOT modify test files." f"Fix the failing tests. Do NOT modify test files."
) )
@ -1591,13 +1597,13 @@ def run_pipeline(
total_tokens += fix_result.get("tokens_used") or 0 total_tokens += fix_result.get("tokens_used") or 0
total_duration += fix_result.get("duration_seconds") or 0 total_duration += fix_result.get("duration_seconds") or 0
results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt}) results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt})
test_run = _run_project_tests(p_path_str) test_run = _run_project_tests(p_path_str, p_test_cmd)
results.append({"role": "_auto_test", "success": test_run["success"], results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True, "output": test_run["output"], "_project_test": True,
"_attempt": auto_test_attempt}) "_attempt": auto_test_attempt})
if not test_run["success"]: if not test_run["success"]:
block_reason = ( block_reason = (
f"Auto-test (make test) failed after {auto_test_attempt} fix attempt(s). " f"Auto-test ({p_test_cmd}) failed after {auto_test_attempt} fix attempt(s). "
f"Last output: {test_run['output'][:500]}" f"Last output: {test_run['output'][:500]}"
) )
models.update_task(conn, task_id, status="blocked", blocked_reason=block_reason) models.update_task(conn, task_id, status="blocked", blocked_reason=block_reason)

View file

@ -640,6 +640,12 @@ def _migrate(conn: sqlite3.Connection):
""") """)
conn.commit() conn.commit()
# Add test_command column to projects (KIN-ARCH-008)
projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
if "test_command" not in projects_cols:
conn.execute("ALTER TABLE projects ADD COLUMN test_command TEXT DEFAULT 'make test'")
conn.commit()
# Rename legacy 'auto' → 'auto_complete' (KIN-063) # Rename legacy 'auto' → 'auto_complete' (KIN-063)
conn.execute( conn.execute(
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"

View file

@ -2122,3 +2122,32 @@ def test_run_kin085_parallel_different_tasks_not_blocked(client):
# Запуск второй задачи должен быть успешным # Запуск второй задачи должен быть успешным
r = client.post("/api/tasks/P1-002/run") r = client.post("/api/tasks/P1-002/run")
assert r.status_code == 202 assert r.status_code == 202
# ---------------------------------------------------------------------------
# KIN-ARCH-008: test_command на уровне проекта — API
# ---------------------------------------------------------------------------
def test_patch_project_test_command(client):
"""KIN-ARCH-008: PATCH /api/projects/{id} с test_command сохраняет значение."""
r = client.patch("/api/projects/p1", json={"test_command": "pytest -v"})
assert r.status_code == 200
assert r.json()["test_command"] == "pytest -v"
def test_create_project_with_test_command(client):
"""KIN-ARCH-008: POST /api/projects с test_command сохраняет значение в БД."""
r = client.post("/api/projects", json={
"id": "p_tc",
"name": "TC Project",
"path": "/tmp/tc",
"test_command": "npm test",
})
assert r.status_code == 200
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT test_command FROM projects WHERE id = 'p_tc'").fetchone()
conn.close()
assert row is not None
assert row[0] == "npm test"

View file

@ -176,6 +176,43 @@ class TestRunProjectTests:
assert result["success"] is False assert result["success"] is False
assert result["returncode"] == 124 assert result["returncode"] == 124
def test_custom_test_command_used(self):
"""KIN-ARCH-008: _run_project_tests вызывает subprocess с переданной командой."""
from agents.runner import _run_project_tests
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "2 passed"
mock_result.stderr = ""
with patch("agents.runner.subprocess.run", return_value=mock_result) as mock_sp, \
patch("agents.runner.shutil.which", return_value=None):
_run_project_tests("/fake/path", test_command="pytest -v")
called_cmd = mock_sp.call_args[0][0]
assert called_cmd[0] == "pytest"
assert "-v" in called_cmd
def test_default_test_command_is_make_test(self):
"""KIN-ARCH-008: без test_command параметра вызывается 'make test'."""
from agents.runner import _run_project_tests
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "OK"
mock_result.stderr = ""
with patch("agents.runner.subprocess.run", return_value=mock_result) as mock_sp, \
patch("agents.runner.shutil.which", return_value=None):
_run_project_tests("/fake/path")
called_cmd = mock_sp.call_args[0][0]
assert called_cmd[0] == "make"
assert "test" in called_cmd
def test_custom_command_not_found_returns_127(self):
"""KIN-ARCH-008: кастомная команда не найдена → returncode 127."""
from agents.runner import _run_project_tests
with patch("agents.runner.subprocess.run", side_effect=FileNotFoundError), \
patch("agents.runner.shutil.which", return_value=None):
result = _run_project_tests("/fake/path", test_command="nonexistent-cmd --flag")
assert result["success"] is False
assert result["returncode"] == 127
def _mock_success(output="done"): def _mock_success(output="done"):
m = MagicMock() m = MagicMock()
@ -302,6 +339,26 @@ class TestAutoTestInPipeline:
mock_tests.assert_not_called() 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_uses_project_test_command(
self, mock_run, mock_tests, mock_autocommit, conn
):
"""KIN-ARCH-008: pipeline передаёт project.test_command в _run_project_tests."""
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, test_command="npm test")
steps = [{"role": "backend_dev", "brief": "implement"}]
run_pipeline(conn, "VDOL-001", steps)
mock_tests.assert_called_once()
called_test_command = mock_tests.call_args[0][1]
assert called_test_command == "npm test"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# (3) Spec-driven workflow route # (3) Spec-driven workflow route

View file

@ -734,3 +734,26 @@ def test_delete_attachment_returns_true(task_conn):
def test_delete_attachment_not_found_returns_false(task_conn): def test_delete_attachment_not_found_returns_false(task_conn):
"""KIN-090: delete_attachment возвращает False если запись не найдена.""" """KIN-090: delete_attachment возвращает False если запись не найдена."""
assert models.delete_attachment(task_conn, 99999) is False assert models.delete_attachment(task_conn, 99999) is False
# ---------------------------------------------------------------------------
# KIN-ARCH-008: test_command на уровне проекта
# ---------------------------------------------------------------------------
def test_schema_project_has_test_command_column(conn):
"""KIN-ARCH-008: таблица projects содержит колонку test_command."""
cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
assert "test_command" in cols
def test_test_command_default_is_make_test(conn):
"""KIN-ARCH-008: новый проект без test_command получает дефолт 'make test'."""
p = models.create_project(conn, "prj_tc", "TC Project", "/tmp/tc")
assert p["test_command"] == "make test"
def test_test_command_can_be_set(conn):
"""KIN-ARCH-008: update_project сохраняет кастомный test_command."""
models.create_project(conn, "prj_tc2", "TC Project 2", "/tmp/tc2")
updated = models.update_project(conn, "prj_tc2", test_command="pytest -v --tb=short")
assert updated["test_command"] == "pytest -v --tb=short"

View file

@ -206,6 +206,7 @@ class ProjectCreate(BaseModel):
ssh_user: str | None = None ssh_user: str | None = None
ssh_key_path: str | None = None ssh_key_path: str | None = None
ssh_proxy_jump: str | None = None ssh_proxy_jump: str | None = None
test_command: str = 'make test'
@model_validator(mode="after") @model_validator(mode="after")
def validate_fields(self) -> "ProjectCreate": def validate_fields(self) -> "ProjectCreate":
@ -222,6 +223,7 @@ class ProjectPatch(BaseModel):
auto_test_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
test_command: str | None = None
project_type: str | None = None project_type: str | None = None
ssh_host: str | None = None ssh_host: str | None = None
ssh_user: str | None = None ssh_user: str | None = None
@ -235,6 +237,7 @@ def patch_project(project_id: str, body: ProjectPatch):
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.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.test_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,
body.ssh_proxy_jump is not None, body.ssh_proxy_jump is not None,
@ -262,6 +265,8 @@ def patch_project(project_id: str, body: ProjectPatch):
if body.deploy_command is not None: if body.deploy_command is not None:
# Empty string = sentinel for clearing (decision #68) # Empty string = sentinel for clearing (decision #68)
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
if body.test_command is not None:
fields["test_command"] = body.test_command
if body.project_type is not None: if body.project_type is not None:
fields["project_type"] = body.project_type fields["project_type"] = body.project_type
if body.ssh_host is not None: if body.ssh_host is not None:
@ -366,6 +371,8 @@ def create_project(body: ProjectCreate):
ssh_key_path=body.ssh_key_path, ssh_key_path=body.ssh_key_path,
ssh_proxy_jump=body.ssh_proxy_jump, ssh_proxy_jump=body.ssh_proxy_jump,
) )
if body.test_command != "make test":
p = models.update_project(conn, body.id, test_command=body.test_command)
conn.close() conn.close()
return p return p