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:
Hitonabi
2026-07-13 20:48:18 +02:00
parent 5436e4df16
commit e5f546a72f
24 changed files with 191 additions and 47 deletions
+2
View File
@@ -493,6 +493,8 @@ export interface IdeenItem {
erstellt: number | null
fertig: number | null
von: string | null
frage?: string | null // bei status=blocked: die Frage/der Grund des Workers
frage_kind?: string | null // needs_input | capability | transient | ""
}
export interface IdeenLogResp {
available: boolean
+62 -4
View File
@@ -1,7 +1,7 @@
import { useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, Send,
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, Send, HelpCircle,
} from "lucide-react"
import { api, type AuftragItem, type IdeenItem, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
@@ -146,6 +146,13 @@ const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }
done: { label: "fertig ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
}
// Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers.
const FRAGE_HINT: Record<string, string> = {
needs_input: "Die Box hat eine Frage:",
capability: "Die Box kam hier nicht weiter:",
transient: "Die Aufgabe hakt:",
}
function IdeenSection() {
const { data, isLoading } = useIdeen()
const qc = useQueryClient()
@@ -154,6 +161,7 @@ function IdeenSection() {
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
const items = data?.items ?? []
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
@@ -192,6 +200,23 @@ function IdeenSection() {
}
}
// 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) {
const t = (antworten[id] || "").trim()
if (t.length < 1 || busy["ans:" + id]) return
setBusy((b) => ({ ...b, ["ans:" + id]: true }))
try {
await api("/api/ideen/antwort", { method: "POST", body: JSON.stringify({ id, antwort: t }) })
setAntworten((a) => ({ ...a, [id]: "" }))
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, ["ans:" + id]: false }))
}
}
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
if (data && !data.available) return null
@@ -235,8 +260,10 @@ function IdeenSection() {
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
const isOpen = openLog === it.id
const canOpen = it.status === "triage" || it.status === "running"
const blocked = it.status === "blocked"
const answering = !!busy["ans:" + it.id]
return (
<li key={it.id} className="flex flex-col gap-2 rounded-xl border border-border/40 bg-background/30 px-3 py-2">
<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")}>
<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" />}
@@ -244,11 +271,11 @@ function IdeenSection() {
</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>
{(it.status === "done" || it.status === "blocked") && (
{(it.status === "done" || blocked) && (
<button
disabled={!!busy[it.id]}
onClick={() => archive(it.id)}
title="Aus der Liste räumen (wandert ins Kanban-Archiv)"
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
@@ -262,6 +289,37 @@ function IdeenSection() {
</button>
)}
</div>
{blocked && (
<div className="space-y-2 border-t border-red-500/20 pt-2">
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
<span>
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
{it.frage
? <span className="text-foreground/90">{it.frage}</span>
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
</span>
</p>
<div className="flex gap-2">
<input
value={antworten[it.id] || ""}
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
placeholder="Deine Antwort — die Box macht damit weiter"
maxLength={2000}
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
/>
<button
onClick={() => answer(it.id)}
disabled={answering || (antworten[it.id] || "").trim().length < 1}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten &amp; weiter
</button>
</div>
</div>
)}
{isOpen && canOpen && (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-border/50 bg-background/50 p-2">