kin: auto-commit after pipeline
This commit is contained in:
parent
f805aff86b
commit
6c2da26b6c
6 changed files with 138 additions and 10 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue