kin: auto-commit after pipeline
This commit is contained in:
parent
f1935d2af2
commit
d42ee4246d
3 changed files with 113 additions and 138 deletions
|
|
@ -51,6 +51,25 @@ def _check_dead_pipelines(db_path: Path) -> None:
|
||||||
except Exception as upd_exc:
|
except Exception as upd_exc:
|
||||||
_logger.error("Watchdog: failed to update pipeline/task: %s", upd_exc)
|
_logger.error("Watchdog: failed to update pipeline/task: %s", upd_exc)
|
||||||
# else: PermissionError (EACCES) — process exists but we can't signal it, skip
|
# else: PermissionError (EACCES) — process exists but we can't signal it, skip
|
||||||
|
|
||||||
|
# Cleanup stale pipelines with no PID (zombie entries)
|
||||||
|
try:
|
||||||
|
stale = conn.execute(
|
||||||
|
"SELECT id, task_id FROM pipelines WHERE status = 'running' AND pid IS NULL "
|
||||||
|
"AND created_at < datetime('now', '-2 hours')"
|
||||||
|
).fetchall()
|
||||||
|
for row in stale:
|
||||||
|
_logger.warning(
|
||||||
|
"Watchdog: stale pipeline %s (no PID) — marking failed (%s)",
|
||||||
|
row["id"], row["task_id"],
|
||||||
|
)
|
||||||
|
models.update_pipeline(conn, row["id"], status="failed")
|
||||||
|
models.update_task(
|
||||||
|
conn, row["task_id"], status="blocked",
|
||||||
|
blocked_reason="Stale pipeline (no PID recorded)",
|
||||||
|
)
|
||||||
|
except Exception as stale_exc:
|
||||||
|
_logger.error("Watchdog: stale cleanup failed: %s", stale_exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.error("Watchdog pass failed: %s", exc)
|
_logger.error("Watchdog pass failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
135
web/api.py
135
web/api.py
|
|
@ -6,6 +6,7 @@ Run: uvicorn web.api:app --reload --port 8420
|
||||||
import glob as _glob
|
import glob as _glob
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -107,16 +108,30 @@ def get_conn():
|
||||||
return init_db(DB_PATH)
|
return init_db(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
def _launch_pipeline_subprocess(task_id: str) -> None:
|
_MAX_CONCURRENT_PIPELINES = 5
|
||||||
"""Spawn `cli.main run {task_id}` in a detached background subprocess.
|
|
||||||
|
|
||||||
Used by auto-trigger (label 'auto') and revise endpoint.
|
|
||||||
Never raises — subprocess errors are logged only.
|
def _spawn_pipeline(task_id: str, extra_log: str = "") -> subprocess.Popen | None:
|
||||||
|
"""Spawn pipeline subprocess with process isolation and concurrency limit.
|
||||||
|
|
||||||
|
Uses start_new_session=True so pipeline survives API restarts.
|
||||||
|
Returns Popen or None on failure/limit. Never raises.
|
||||||
"""
|
"""
|
||||||
import os
|
conn = get_conn()
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM pipelines WHERE status = 'running'"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
if count >= _MAX_CONCURRENT_PIPELINES:
|
||||||
|
_logger.warning(
|
||||||
|
"Concurrency limit (%d/%d): skipping %s",
|
||||||
|
count, _MAX_CONCURRENT_PIPELINES, task_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
kin_root = Path(__file__).parent.parent
|
kin_root = Path(__file__).parent.parent
|
||||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
|
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
||||||
cmd.append("--allow-write")
|
"run", task_id, "--allow-write"]
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
if 'SSH_AUTH_SOCK' not in env:
|
if 'SSH_AUTH_SOCK' not in env:
|
||||||
_socks = _glob.glob('/private/tmp/com.apple.launchd.*/Listeners')
|
_socks = _glob.glob('/private/tmp/com.apple.launchd.*/Listeners')
|
||||||
|
|
@ -125,16 +140,21 @@ def _launch_pipeline_subprocess(task_id: str) -> None:
|
||||||
env["KIN_NONINTERACTIVE"] = "1"
|
env["KIN_NONINTERACTIVE"] = "1"
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd, cwd=str(kin_root),
|
||||||
cwd=str(kin_root),
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, env=env,
|
||||||
stderr=subprocess.DEVNULL,
|
start_new_session=True,
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
_logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid)
|
_logger.info("Pipeline spawned for %s%s, pid=%d", task_id, extra_log, proc.pid)
|
||||||
|
return proc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning("Failed to launch pipeline for %s: %s", task_id, exc)
|
_logger.warning("Failed to spawn pipeline for %s: %s", task_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_pipeline_subprocess(task_id: str) -> None:
|
||||||
|
"""Backward-compat wrapper for auto-trigger and revise."""
|
||||||
|
_spawn_pipeline(task_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -621,28 +641,9 @@ def start_project_phase(project_id: str):
|
||||||
models.update_task(conn, task_id, status="in_progress")
|
models.update_task(conn, task_id, status="in_progress")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
kin_root = Path(__file__).parent.parent
|
proc = _spawn_pipeline(task_id, extra_log=f" (phase {active_phase['id']})")
|
||||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
if proc is None:
|
||||||
"run", task_id]
|
raise HTTPException(429, "Concurrency limit reached — try again later")
|
||||||
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
|
|
||||||
|
|
||||||
import os
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["KIN_NONINTERACTIVE"] = "1"
|
|
||||||
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=str(kin_root),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
_logger.info("Phase agent started for task %s (phase %d), pid=%d",
|
|
||||||
task_id, active_phase["id"], proc.pid)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Failed to start phase agent: {e}")
|
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"status": "started", "phase_id": active_phase["id"], "task_id": task_id},
|
{"status": "started", "phase_id": active_phase["id"], "task_id": task_id},
|
||||||
|
|
@ -1125,29 +1126,10 @@ def run_task(task_id: str):
|
||||||
# Set task to in_progress immediately so UI updates
|
# Set task to in_progress immediately so UI updates
|
||||||
models.update_task(conn, task_id, status="in_progress")
|
models.update_task(conn, task_id, status="in_progress")
|
||||||
conn.close()
|
conn.close()
|
||||||
# Launch kin run in background subprocess
|
|
||||||
kin_root = Path(__file__).parent.parent
|
|
||||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
|
||||||
"run", task_id]
|
|
||||||
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
|
|
||||||
|
|
||||||
import os
|
proc = _spawn_pipeline(task_id)
|
||||||
env = os.environ.copy()
|
if proc is None:
|
||||||
env["KIN_NONINTERACTIVE"] = "1"
|
raise HTTPException(429, "Concurrency limit reached — try again later")
|
||||||
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=str(kin_root),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
import logging
|
|
||||||
logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Failed to start pipeline: {e}")
|
|
||||||
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1416,23 +1398,7 @@ def _trigger_sysadmin_scan(conn, project_id: str, env: dict) -> str:
|
||||||
)
|
)
|
||||||
models.update_task(conn, task_id, status="in_progress")
|
models.update_task(conn, task_id, status="in_progress")
|
||||||
|
|
||||||
kin_root = Path(__file__).parent.parent
|
_spawn_pipeline(task_id, extra_log=" (sysadmin scan)")
|
||||||
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
|
|
||||||
cmd.append("--allow-write")
|
|
||||||
import os as _os
|
|
||||||
env_vars = _os.environ.copy()
|
|
||||||
env_vars["KIN_NONINTERACTIVE"] = "1"
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=str(kin_root),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env=env_vars,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning("Failed to start sysadmin scan for %s: %s", task_id, e)
|
|
||||||
|
|
||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
|
@ -1808,22 +1774,7 @@ def send_chat_message(project_id: str, body: ChatMessageIn):
|
||||||
)
|
)
|
||||||
task = t
|
task = t
|
||||||
|
|
||||||
import os as _os
|
_spawn_pipeline(task_id, extra_log=" (chat)")
|
||||||
env_vars = _os.environ.copy()
|
|
||||||
env_vars["KIN_NONINTERACTIVE"] = "1"
|
|
||||||
kin_root = Path(__file__).parent.parent
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
|
||||||
"run", task_id, "--allow-write"],
|
|
||||||
cwd=str(kin_root),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
env=env_vars,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning("Failed to start pipeline for chat task %s: %s", task_id, e)
|
|
||||||
|
|
||||||
assistant_content = f"Создал задачу {task_id}: {title}"
|
assistant_content = f"Создал задачу {task_id}: {title}"
|
||||||
assistant_msg = models.add_chat_message(
|
assistant_msg = models.add_chat_message(
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,8 @@ function phaseStatusColor(s: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab groups
|
// Tab groups
|
||||||
const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions', 'modules', 'links'] as const
|
const PRIMARY_TABS = ['tasks', 'kanban', 'decisions'] as const
|
||||||
const MORE_TABS = ['environments', 'settings'] as const
|
const MORE_TABS = ['phases', 'modules', 'environments', 'links', 'settings'] as const
|
||||||
|
|
||||||
function tabLabel(tab: string): string {
|
function tabLabel(tab: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|
@ -1193,16 +1193,7 @@ async function addDecision() {
|
||||||
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded">✕</button>
|
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Mode toggle (always visible) -->
|
<!-- ⚙ Mode dropdown (Auto, Autocommit, AutoTest, Worktrees) -->
|
||||||
<button
|
|
||||||
:data-mode="autoMode ? 'auto' : 'review'"
|
|
||||||
@click="toggleMode"
|
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
|
||||||
:class="autoMode ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20 hover:bg-yellow-900/50' : 'text-gray-400 border-gray-700 bg-gray-800/50 hover:bg-gray-800'"
|
|
||||||
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
|
||||||
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
|
||||||
</button>
|
|
||||||
<!-- ⚙ Aux settings dropdown (Autocommit, AutoTest, Worktrees) -->
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div>
|
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1210,8 +1201,8 @@ async function addDecision() {
|
||||||
:data-mode="autoMode ? 'auto' : 'review'"
|
:data-mode="autoMode ? 'auto' : 'review'"
|
||||||
@click="showModeMenu = !showModeMenu"
|
@click="showModeMenu = !showModeMenu"
|
||||||
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors"
|
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors"
|
||||||
:class="(autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
|
:class="(autoMode || autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
|
||||||
⚙ ▾
|
⚙ Mode ▾
|
||||||
</button>
|
</button>
|
||||||
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
|
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1558,38 +1549,52 @@ async function addDecision() {
|
||||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="toggleMode"
|
<!-- ⚙ Mode dropdown (Auto, Autocommit, AutoTest, Worktrees) -->
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
<div class="relative">
|
||||||
:class="autoMode
|
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div>
|
||||||
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
|
<button
|
||||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
data-testid="mode-menu-trigger"
|
||||||
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
:data-mode="autoMode ? 'auto' : 'review'"
|
||||||
{{ autoMode ? '🔓 Авто' : '🔒 Review' }}
|
@click="showModeMenu = !showModeMenu"
|
||||||
</button>
|
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors"
|
||||||
<button @click="toggleAutocommit"
|
:class="(autoMode || autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
⚙ Mode ▾
|
||||||
:class="autocommit
|
</button>
|
||||||
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50'
|
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
|
||||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
<button
|
||||||
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
|
data-testid="mode-toggle-auto"
|
||||||
{{ autocommit ? '✓ Автокомит' : 'Автокомит' }}
|
@click="toggleMode(); showModeMenu = false"
|
||||||
</button>
|
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
|
||||||
<button @click="toggleAutoTest"
|
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
<span>{{ autoMode ? '🔓 Auto' : '🔒 Review' }}</span>
|
||||||
:class="autoTest
|
<span :class="autoMode ? 'text-yellow-400' : 'text-gray-600'" class="text-[10px]">{{ autoMode ? 'on' : 'off' }}</span>
|
||||||
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
|
</button>
|
||||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
<button
|
||||||
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
data-testid="mode-toggle-autocommit"
|
||||||
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
|
@click="toggleAutocommit"
|
||||||
</button>
|
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
|
||||||
<button @click="toggleWorktrees"
|
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
<span>Autocommit</span>
|
||||||
:class="worktrees
|
<span :class="autocommit ? 'text-green-400' : 'text-gray-600'" class="text-[10px]">{{ autocommit ? 'on' : 'off' }}</span>
|
||||||
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
|
</button>
|
||||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
<button
|
||||||
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
data-testid="mode-toggle-autotest"
|
||||||
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
|
@click="toggleAutoTest"
|
||||||
</button>
|
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
|
||||||
|
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
||||||
|
<span>{{ t('projectView.auto_test_label') }}</span>
|
||||||
|
<span :class="autoTest ? 'text-blue-400' : 'text-gray-600'" class="text-[10px]">{{ autoTest ? 'on' : 'off' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="mode-toggle-worktrees"
|
||||||
|
@click="toggleWorktrees"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
|
||||||
|
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
||||||
|
<span>{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}</span>
|
||||||
|
<span :class="worktrees ? 'text-teal-400' : 'text-gray-600'" class="text-[10px]">{{ worktrees ? 'on' : 'off' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button @click="runAudit" :disabled="auditLoading"
|
<button @click="runAudit" :disabled="auditLoading"
|
||||||
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
|
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
|
||||||
title="Check which pending tasks are already done">
|
title="Check which pending tasks are already done">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue