Queue-Steuerung: Stopp/Neuer-Versuch/Prio + Haenger-Warnung (UI+Telegram)
Karten lassen sich gezielt anhalten (generischer Block + Prozessbaum-Kill), frisch starten (Retry beendet Worker samt Kindern - uvicorn-Falle) und per Prio-Pfeilen umsortieren (Dispatcher zieht priority DESC nativ). Laufende Karten ohne Heartbeat >10 min bekommen ein Warn-Badge; der Ketten-Digest alarmiert nach 15 min per Telegram, inkl. Kontext-Verdichtungs-Hinweis. Ausloeser: Scaffold-Worker hing 45 min an selbst gestartetem uvicorn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,9 @@ def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
||||
it.setdefault("wartet_auf", [])
|
||||
it.setdefault("notiz", None)
|
||||
it.setdefault("projekt", None)
|
||||
it.setdefault("prio", 0)
|
||||
it.setdefault("puls_alter", None)
|
||||
it.setdefault("gestartet", None)
|
||||
if not items or not KANBAN_DB.is_file():
|
||||
return []
|
||||
by_id = {it["id"]: it for it in items if it.get("id")}
|
||||
@@ -136,6 +139,18 @@ def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
||||
links = [(p, c) for p, c in con.execute(
|
||||
"SELECT parent_id, child_id FROM task_links")
|
||||
if p in by_id or c in by_id]
|
||||
# Prio (für die Tausch-Knöpfe) + Lebenszeichen (Hänger-Warnung): ein Rutsch.
|
||||
ph = ",".join("?" * len(by_id))
|
||||
jetzt = time.time()
|
||||
for tid, prio, hb, started, pid in con.execute(
|
||||
f"SELECT id, priority, last_heartbeat_at, started_at, worker_pid"
|
||||
f" FROM tasks WHERE id IN ({ph})", list(by_id)):
|
||||
it = by_id[tid]
|
||||
it["prio"] = prio or 0
|
||||
if it.get("status") == "running":
|
||||
it["gestartet"] = started
|
||||
puls = hb or started
|
||||
it["puls_alter"] = int(jetzt - puls) if puls else None
|
||||
for it in items:
|
||||
if it.get("status") != "running":
|
||||
continue
|
||||
@@ -369,6 +384,161 @@ def ergebnis_of(task_id: str) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
# ── Karten-Steuerung (Stopp / Neuer Versuch / Prio) — User-Wunsch 20.07. ─────
|
||||
# Die CLI kann blocken/entsperren, aber weder haengende Worker beenden noch die
|
||||
# Prioritaet aendern → fuer genau diese zwei Faelle eine eng begrenzte Direkt-
|
||||
# Schreibstelle in die kanban.db (BEGIN IMMEDIATE, nur tasks-Spalten). Alles
|
||||
# andere laeuft weiter ueber die CLI.
|
||||
|
||||
|
||||
def _task_row(task_id: str) -> dict | None:
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
r = con.execute("SELECT id, status, worker_pid, priority FROM tasks WHERE id=?",
|
||||
(task_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _kill_tree(pid: int) -> None:
|
||||
"""Worker samt Kind-Prozessen beenden (TERM, dann KILL). Ohne das bleiben
|
||||
Test-Server (uvicorn & Co.) als Waisen zurueck — live gesehen 20.07."""
|
||||
import signal
|
||||
|
||||
def kinder(p: int) -> list[int]:
|
||||
try:
|
||||
out = subprocess.run(["pgrep", "-P", str(p)], capture_output=True, text=True, timeout=5)
|
||||
return [int(x) for x in out.stdout.split()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def baum(p: int) -> list[int]:
|
||||
alle = [p]
|
||||
for k in kinder(p):
|
||||
alle.extend(baum(k))
|
||||
return alle
|
||||
|
||||
pids = baum(pid)
|
||||
for p in reversed(pids):
|
||||
try:
|
||||
os.kill(p, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(2)
|
||||
for p in reversed(pids):
|
||||
try:
|
||||
os.kill(p, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _requeue(task_id: str) -> None:
|
||||
"""Karte sauber zurueck auf todo (Claim/PID/Zaehler leeren) — der Dispatcher
|
||||
spawnt sie beim naechsten Tick frisch (Caps gelten)."""
|
||||
con = sqlite3.connect(str(KANBAN_DB), timeout=15)
|
||||
try:
|
||||
con.execute("PRAGMA busy_timeout=10000")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
con.execute(
|
||||
"UPDATE tasks SET status='todo', claim_lock=NULL, claim_expires=NULL,"
|
||||
" worker_pid=NULL, current_run_id=NULL, last_heartbeat_at=NULL,"
|
||||
" started_at=NULL, consecutive_failures=0 WHERE id=?", (task_id,))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def stop_task(task_id: str) -> dict:
|
||||
"""Karte gezielt anhalten: generischer Block (bleibt liegen, kein Auto-Unblock-
|
||||
Kind) + laufenden Worker-Prozessbaum beenden."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
|
||||
row = _task_row(task_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Karte nicht gefunden."}
|
||||
try:
|
||||
r = _hermes(["block", task_id, "Vom", "Commander", "gestoppt"])
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": (r.stderr or r.stdout or "Blocken fehlgeschlagen").strip()[:300]}
|
||||
if row.get("status") == "running" and row.get("worker_pid"):
|
||||
_kill_tree(int(row["worker_pid"]))
|
||||
_list_cache["ts"] = 0.0
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def retry_task(task_id: str) -> dict:
|
||||
"""Neuer Versuch: haengende/laufende Karte → Worker-Baum beenden + frisch auf todo;
|
||||
blockierte Karte → entsperren. Der Dispatcher uebernimmt den Rest (Caps gelten)."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
|
||||
row = _task_row(task_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Karte nicht gefunden."}
|
||||
status = row.get("status")
|
||||
try:
|
||||
if status == "blocked":
|
||||
r = _hermes(["unblock", task_id, "--reason", "Neuer Versuch vom Commander"])
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": (r.stderr or r.stdout or "Entsperren fehlgeschlagen").strip()[:300]}
|
||||
else:
|
||||
if status == "running" and row.get("worker_pid"):
|
||||
_kill_tree(int(row["worker_pid"]))
|
||||
_requeue(task_id)
|
||||
try:
|
||||
_hermes(["comment", task_id, "Neuer", "Versuch", "vom", "Commander", "(Karte zurueckgesetzt)"], timeout=20)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Neuer Versuch fehlgeschlagen: {exc}"}
|
||||
_list_cache["ts"] = 0.0
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Karte {task_id} wurde auf Commander-Wunsch neu angestoßen.",
|
||||
"[Ideen-Queue]", "ideen-queue", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def prio_task(task_id: str, richtung: str) -> dict:
|
||||
"""Prio hoch/runter (Dispatcher zieht ready-Karten nach `priority DESC, created ASC`
|
||||
→ hoch = frueher dran). Direkt-Schreibstelle, die CLI kennt keine Prio-Aenderung."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
|
||||
if richtung not in ("hoch", "runter"):
|
||||
return {"ok": False, "error": "richtung muss 'hoch' oder 'runter' sein."}
|
||||
delta = 1 if richtung == "hoch" else -1
|
||||
try:
|
||||
con = sqlite3.connect(str(KANBAN_DB), timeout=15)
|
||||
try:
|
||||
con.execute("PRAGMA busy_timeout=10000")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
cur = con.execute("UPDATE tasks SET priority = COALESCE(priority,0) + ? WHERE id=?",
|
||||
(delta, task_id))
|
||||
con.commit()
|
||||
if cur.rowcount == 0:
|
||||
return {"ok": False, "error": "Karte nicht gefunden."}
|
||||
finally:
|
||||
con.close()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Prio-Änderung fehlgeschlagen: {exc}"}
|
||||
_list_cache["ts"] = 0.0
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def log_of(task_id: str) -> dict:
|
||||
"""Worker-Log einer Idee (hermes kanban log <id>), ANSI entfernt, letzte ~120 Zeilen."""
|
||||
if not _available():
|
||||
|
||||
Reference in New Issue
Block a user