Faden 4: Antwort-Feld fuer haengende Aufgaben (needs_input)
Sackgasse behoben: ein Worker blockte eine Aufgabe mit einer Frage (kanban block --kind needs_input), aber die Zentrale zeigte nur den roten Status "haengt - braucht dich" - ohne die Frage und ohne Eingabefeld. Der Worker blieb so dauerhaft stehen. Backend (services/ideen.py): - _blocker_frage(): liest die Worker-Frage aus dem juengsten blocked-Event (payload.reason/kind), Fallback: BLOCKED:-Kommentar. - list_queue() reichert blockierte Karten mit frage + frage_kind an (max 6 Extra-show-Aufrufe pro Poll). - answer_task(): unblock --reason schreibt die Antwort als Kommentar und stellt die Karte auf ready -> Dispatcher spawnt den Worker neu, der die Antwort im Kontext vorfindet. Mit ID-/Laengen-Validierung. Router: POST /api/ideen/antwort. Frontend (AuftragsbuchView): blockierte Karten zeigen die Frage (je nach Grund "Die Box hat eine Frage:" / "kam hier nicht weiter:") plus Antwortfeld + "Antworten & weiter". Fallback-Text ohne Grund. Vertraegt sich mit dem Live-Log-Blick (nur triage/running). Verifiziert: E2E gegen die echte Box-Kanban-CLI (Frage auslesen, answer_task entsperrt + protokolliert, Fehlerpfade), py_compile + Frontend-Build gruen, Antwort-UI im Dev-Server gerendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,11 @@ class TaskIn(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class AntwortIn(BaseModel):
|
||||
id: str
|
||||
antwort: str
|
||||
|
||||
|
||||
@router.get("/ideen")
|
||||
def list_queue() -> dict:
|
||||
return ideen.list_queue()
|
||||
@@ -31,6 +36,14 @@ def add_idea(body: IdeeIn) -> dict:
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/antwort")
|
||||
def answer(body: AntwortIn) -> dict:
|
||||
res = ideen.answer_task(body.id, body.antwort)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren")
|
||||
def archive(body: TaskIn) -> dict:
|
||||
res = ideen.archive_task(body.id)
|
||||
|
||||
@@ -58,6 +58,36 @@ def _parse_json(stdout: str):
|
||||
return None
|
||||
|
||||
|
||||
def _blocker_frage(task_id: str) -> dict | None:
|
||||
"""Für eine blockierte Aufgabe: die Frage/den Grund des Workers holen (needs_input etc.).
|
||||
|
||||
Die Wahrheit steckt im jüngsten `blocked`-Event (payload.reason/kind); fällt das leer aus,
|
||||
greifen wir auf den letzten „BLOCKED:"-Kommentar zurück. Ein Extra-`show`-Aufruf pro
|
||||
hängender Karte ist billig — die gibt es fast nie und wenn, dann nur eine Handvoll.
|
||||
"""
|
||||
try:
|
||||
r = _hermes(["show", task_id, "--json"], timeout=30)
|
||||
d = _parse_json(r.stdout)
|
||||
except Exception:
|
||||
log.warning("ideen: kanban show %s fehlgeschlagen", task_id, exc_info=True)
|
||||
return None
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
|
||||
frage, kind = "", ""
|
||||
for ev in d.get("events") or []: # chronologisch → letztes blocked = aktive Frage
|
||||
if isinstance(ev, dict) and ev.get("kind") == "blocked":
|
||||
p = ev.get("payload") or {}
|
||||
frage = (p.get("reason") or "").strip()
|
||||
kind = (p.get("kind") or "").strip()
|
||||
if not frage: # Fallback: letzter „BLOCKED:"-Kommentar
|
||||
for c in d.get("comments") or []:
|
||||
body = (c.get("body") or "").strip()
|
||||
if body.startswith("BLOCKED:"):
|
||||
frage = body[len("BLOCKED:"):].strip()
|
||||
return {"frage": frage[:800], "kind": kind}
|
||||
|
||||
|
||||
def list_queue() -> dict:
|
||||
"""Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI."""
|
||||
if not _available():
|
||||
@@ -89,9 +119,20 @@ def list_queue() -> dict:
|
||||
"erstellt": t.get("created_at"),
|
||||
"fertig": t.get("completed_at"),
|
||||
"von": t.get("created_by"),
|
||||
"frage": None, # bei blocked die Worker-Frage (unten nachgeladen)
|
||||
"frage_kind": None,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Hängende Karten anreichern: die Frage des Workers holen, damit die Zentrale sie
|
||||
# beantworten kann (Sackgasse-Fix). Deckel gegen Ausreißer, damit ein Poll nie hängt.
|
||||
for it in [i for i in items if i["status"] == "blocked"][:6]:
|
||||
info = _blocker_frage(it["id"])
|
||||
if info:
|
||||
it["frage"] = info["frage"]
|
||||
it["frage_kind"] = info["kind"]
|
||||
|
||||
offen = sum(1 for i in items if i["status"] not in ("done",))
|
||||
data = {"available": True, "items": items, "offen": offen}
|
||||
_list_cache.update(ts=now, data=data)
|
||||
@@ -126,6 +167,36 @@ def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui") -> dict:
|
||||
return {"ok": True, "id": task["id"], "status": task.get("status")}
|
||||
|
||||
|
||||
def answer_task(task_id: str, antwort: str) -> dict:
|
||||
"""Eine hängende Aufgabe beantworten: Antwort als Kommentar protokollieren + entsperren.
|
||||
|
||||
`unblock --reason` schreibt die Antwort als „UNBLOCK:"-Kommentar und stellt die Karte auf
|
||||
ready — der Dispatcher spawnt den Worker neu, der die Antwort im Task-Kontext vorfindet und
|
||||
weitermacht. Genau der Ausweg aus der Sackgasse „Aufgabe fragt, aber niemand kann antworten".
|
||||
"""
|
||||
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}"}
|
||||
antwort = (antwort or "").strip()
|
||||
if not (1 <= len(antwort) <= 2000):
|
||||
return {"ok": False, "error": "Bitte eine Antwort zwischen 1 und 2000 Zeichen."}
|
||||
try:
|
||||
r = _hermes(["unblock", task_id, "--reason", antwort])
|
||||
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 "Entsperren fehlgeschlagen").strip()[:300]}
|
||||
_list_cache["ts"] = 0.0 # nächster Poll zeigt den neuen Status (ready/running) sofort
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Deine Antwort ging an die hängende Aufgabe {task_id} — die Box macht weiter.",
|
||||
"[Ideen-Queue]", "ideen-queue", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def archive_task(task_id: str) -> dict:
|
||||
"""Erledigtes/Verworfenes aus der Sicht räumen (Kanban-Archiv, nichts wird gelöscht)."""
|
||||
if not _available():
|
||||
|
||||
Reference in New Issue
Block a user