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:
Hitonabi
2026-07-20 13:26:09 +02:00
parent a91d70d9b7
commit 8153d14d9f
30 changed files with 389 additions and 54 deletions
+3
View File
@@ -523,6 +523,9 @@ export interface IdeenItem {
wartet_auf?: string[] // Titel der offenen Vorgänger (max. 3, für die Anzeige)
notiz?: string | null // bei running: jüngste Heartbeat-Notiz des Workers
projekt?: string | null // Familien-Schlüssel (verkettete Karten) — null = Einzelkarte
prio?: number // Dispatcher-Reihenfolge: priority DESC, created ASC
puls_alter?: number | null // bei running: Sekunden seit letztem Lebenszeichen (Hänger-Warnung)
gestartet?: number | null // bei running: started_at (Epoch)
}
// Karten-Familie (verkettete Karten = ein Projekt) für die gruppierte Queue-Sicht
export interface IdeenProjekt {
+75 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Layers, Lightbulb, Send, HelpCircle,
RotateCcw, Square, ArrowUp, ArrowDown,
} from "lucide-react"
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenProjekt, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
@@ -160,7 +161,7 @@ const FRAGE_HINT: Record<string, string> = {
function IdeenSection() {
const { data, isLoading } = useIdeen()
const qc = useQueryClient()
const { showAlert, dialogElement } = useDialog()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [text, setText] = useState("")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
@@ -204,6 +205,19 @@ function IdeenSection() {
}
}
// Karten-Steuerung (Stopp / Neuer Versuch / Prio) — ein Helfer für alle drei Endpunkte.
async function steuer(prefix: string, id: string, pfad: string, body: object) {
setBusy((b) => ({ ...b, [prefix + id]: true }))
try {
await api(pfad, { method: "POST", body: JSON.stringify(body) })
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [prefix + id]: false }))
}
}
// Hängende Aufgabe beantworten: die Antwort geht als Kommentar an die Karte, der Worker
// macht damit weiter (Ausweg aus der Sackgasse „fragt, aber niemand kann antworten").
async function answer(id: string) {
@@ -231,15 +245,74 @@ function IdeenSection() {
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
const blocked = it.status === "blocked"
const answering = !!busy["ans:" + it.id]
// Hänger-Verdacht: läuft, aber seit >10 min kein Lebenszeichen (Heartbeat) — der
// Prozess kann trotzdem „leben" (wartet z. B. auf einen selbst gestarteten Server).
const haengt = it.status === "running" && (it.puls_alter ?? 0) > 600
const wartend = it.status === "todo" || it.status === "ready" || it.status === "scheduled"
return (
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : "border-border/40")}>
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
<div className="flex items-center gap-2">
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
{haengt && (
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
</span>
)}
{(it.prio ?? 0) !== 0 && (
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
)}
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
{wartend && (
<span className="flex shrink-0 items-center">
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
title="Vorziehen (Prio +1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
title="Zurückstellen (Prio 1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowDown className="h-3.5 w-3.5" />
</button>
</span>
)}
{(it.status === "running" || blocked) && (
<button
disabled={!!busy["rt:" + it.id]}
onClick={() =>
showConfirm("Neuer Versuch?",
it.status === "running"
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
title="Neuer Versuch — Karte frisch starten"
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "running" || wartend || it.status === "triage") && (
<button
disabled={!!busy["st:" + it.id]}
onClick={() =>
showConfirm("Karte stoppen?",
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
title="Stoppen — Karte anhalten"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "done" || blocked) && (
<button
disabled={!!busy[it.id]}