This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/blunix/localServices/board-agent/trigger.py

2245 lines
98 KiB
Python

#!/usr/bin/env python3
"""Tailnet-only kanban board: static frontend + trigger API for the intent
lifecycle pilot (docs/intent/kanban-board-admin-trigger.md).
Cards backed by a docs/intent/*.md file default to phase "geplant" until a
board move records a different phase in BOARD_STATE_FILE. Manually created
"idea" cards live only in that state file. Moving an intent-backed card
from "geplant" to "in_umsetzung" is the one active transition: it queues a
job for ../board-agent/worker.py (systemd-path-triggered, see
../board-agent.nix) instead of just recording the move. Every other move,
for every card, is plain bookkeeping with no side effect -- see the intent
document's explicit scope limits.
"""
import json
import state_store
import workflow
import os
import re
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
import fanout
import metrics
import orchestration
import telemetry
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,60}$")
CARD_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,80}")
SUPPORTED_RUNTIMES = ("codex", "claude", "openrouter")
COLUMN_CONFIG_DEFAULTS = {
"in_umsetzung": {
"runtime": "codex",
"model": "",
"reasoning_effort": "",
"iteration_limit": 20,
},
# The board's "leicht" lane -- its own dedicated agent-codex-builder-2
# instance/inbox (see docs/intent/board-builder-weighted-lanes.md),
# defaulting to a cheap OpenRouter model instead of "in_umsetzung"'s
# Codex default. Same config shape/API, just a second column.
"in_umsetzung_leicht": {
"runtime": "openrouter",
"model": "deepseek/deepseek-v3.2",
"reasoning_effort": "",
"iteration_limit": 20,
},
"review": {"runtime": "codex", "model": "", "reasoning_effort": ""},
"recherche_laeuft": {"runtime": "codex", "model": "", "reasoning_effort": ""},
"operator_angefordert": {"runtime": "codex", "model": "", "reasoning_effort": ""},
"klaerung_reparatur": {
"runtime": "codex", "model": "", "reasoning_effort": "", "iteration_limit": 20,
},
}
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,127}$")
REASONING_EFFORT_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
PHASES = (
"idea",
"geplant",
"in_umsetzung",
# The board's second, dedicated Builder column ("leicht") -- see
# COLUMN_CONFIG_DEFAULTS and docs/intent/board-builder-weighted-lanes.md.
# Same rank as "in_umsetzung" in PHASE_RANK below: both are valid,
# parallel successors of "geplant".
"in_umsetzung_leicht",
"review",
"bereit_fuer_deployment",
"planung_mit_feedback",
"deployed",
"archiviert",
# Manual escalation column for a card stuck at the Builder iteration
# limit even after its one automatic retry -- see
# docs/intent/board-builder-limit-reached-auto-split.md. Deliberately
# NOT in PHASE_RANK (see update_phase(), same treatment as archiviert):
# it is a sibling escalation path, not a further pipeline stage.
# "aufgeteilt" (the split outcome) is intentionally absent here -- it is
# a worker-only terminal phase, never a legal PATCH target from the API.
"aufteilen_angefordert",
"operator_angefordert",
"operator_laeuft",
"wartet_auf_kinder",
"durch_kinder_blockiert",
# Distinct from "durch_kinder_blockiert": this is set when the Operator
# step itself never produced children -- either a considered
# needs_human decision or the Operator job raising an exception -- so
# "blocked by children" would be a lie (there are none). See
# orchestration.apply_needs_human() and worker.py's operator except
# branch.
"operator_klaerung_noetig",
"fixer_angefordert",
"fixer_laeuft",
"fixer_rueckfrage",
"fixer_fehlgeschlagen",
"klaerung_reparatur",
"inspector_laeuft",
"inspector_rueckfrage",
"inspector_fehlgeschlagen",
)
CLARIFICATION_TASK_TYPES = {"implementation_status", "route_next", "repair"}
ROUTING_ACTION_PHASES = {
"geplant", "in_umsetzung", "in_umsetzung_leicht", "review",
"archiviert", "recherche_angefordert", "recherche_laeuft",
}
# Pipeline rank per phase, used only to reject forward moves that skip a
# stage. bereit_fuer_deployment and planung_mit_feedback share a rank: both
# are valid successors of review and valid predecessors of deployed (see
# docs/intent/board-intent-detail-and-transition-guardrails.md). Backward
# moves (new rank <= current rank) are never guarded.
PHASE_RANK = {
"idea": 0,
"geplant": 1,
"in_umsetzung": 2,
"in_umsetzung_leicht": 2,
"review": 3,
"bereit_fuer_deployment": 4,
"planung_mit_feedback": 4,
"deployed": 5,
}
RESEARCH_PHASES = (
"recherche_angefordert",
"recherche_laeuft",
"recherche_fertig",
"recherche_fehlgeschlagen",
"archiviert",
)
RESEARCH_PHASE_RANK = {
"recherche_angefordert": 0,
"recherche_laeuft": 1,
"recherche_fertig": 2,
"recherche_fehlgeschlagen": 2,
}
# Single-process ThreadingHTTPServer: this lock keeps a create/patch/delete
# from interleaving with another one, which is what makes the "only from
# geplant" trigger guard in update_phase() below actually safe against a
# double-drag race instead of just usually working.
STATE_LOCK = state_store.StateLock()
def validate_runtime(runtime):
value = str(runtime or "").strip().lower()
if value not in SUPPORTED_RUNTIMES:
raise ValueError("Runtime muss exakt codex, claude oder openrouter sein.")
return value
def board_runtime():
return validate_runtime(load_state().get("runtime", os.environ.get("BOARD_RUNTIME_DEFAULT", "codex")))
def set_board_runtime(runtime):
runtime = validate_runtime(runtime)
with STATE_LOCK:
state = load_state()
state["runtime"] = runtime
save_state(state)
return runtime
def validate_column_config_value(column, payload):
if column not in COLUMN_CONFIG_DEFAULTS:
raise ValueError("unbekannte Spalte")
if not isinstance(payload, dict):
raise ValueError("body must be a JSON object")
allowed = COLUMN_CONFIG_DEFAULTS[column]
unknown = set(payload) - set(allowed)
if unknown:
raise ValueError(f"unbekanntes Feld: {sorted(unknown)[0]}")
validated = {}
if "runtime" in payload:
validated["runtime"] = validate_runtime(payload["runtime"])
if "model" in payload:
value = str(payload["model"])
if value and not MODEL_RE.fullmatch(value):
raise ValueError("ungültiges model")
if len(value) > 128:
raise ValueError("ungültiges model")
validated["model"] = value
if "reasoning_effort" in payload:
value = str(payload["reasoning_effort"])
if value and not REASONING_EFFORT_RE.fullmatch(value):
raise ValueError("ungültiges reasoning_effort")
validated["reasoning_effort"] = value
if "iteration_limit" in payload:
value = payload["iteration_limit"]
if isinstance(value, bool) or not isinstance(value, int) or not 5 <= value <= 40:
raise ValueError("iteration_limit muss eine Ganzzahl zwischen 5 und 40 sein")
validated["iteration_limit"] = value
return validated
def load_column_config():
stored = load_state().get("column_config", {})
result = {}
for column, defaults in COLUMN_CONFIG_DEFAULTS.items():
values = stored.get(column, {}) if isinstance(stored, dict) else {}
result[column] = {
field: values.get(field, default) for field, default in defaults.items()
}
return result
def set_column_config(column, updates):
validated = validate_column_config_value(column, updates)
with STATE_LOCK:
state = load_state()
config = load_column_config()
config[column].update(validated)
state["column_config"] = config
save_state(state)
return config[column]
def now_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def state_file():
return Path(os.environ["BOARD_STATE_FILE"])
def load_state():
return state_store.load()
def save_state(data):
with state_store.transaction():
state_store.save(data)
def intent_title(path):
try:
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
except OSError:
pass
return path.stem
def list_intent_cards():
intent_dir = Path(os.environ["BOARD_INTENT_DIR"])
cards = {}
if not intent_dir.is_dir():
return cards
for md in sorted(intent_dir.glob("*.md")):
slug = md.stem
if not SLUG_RE.match(slug):
continue
cards[slug] = {
"id": slug,
"source": "intent",
"title": intent_title(md),
"intent_path": f"docs/intent/{md.name}",
}
return cards
def list_research_cards():
research_dir = Path(
os.environ.get(
"BOARD_RESEARCH_INTENT_DIR",
str(Path(os.environ["BOARD_INTENT_DIR"]).parent / "research-intent"),
)
)
cards = {}
if not research_dir.is_dir():
return cards
for md in sorted(research_dir.glob("*.md")):
slug = md.stem
if not SLUG_RE.match(slug):
continue
cards[slug] = {
"id": slug,
"source": "research",
"title": intent_title(md),
"intent_path": f"docs/research-intent/{md.name}",
}
return cards
def stored_intent_card(card_id, stored):
"""Return a validated state-only card for an intent whose file is absent."""
slug = card_id
path = f"docs/intent/{slug}.md"
if (
stored.get("source") != "intent"
or not SLUG_RE.fullmatch(slug)
):
return None
return {
"id": slug,
"source": "intent",
"title": str(stored.get("title") or slug),
"intent_path": path,
"source_missing": True,
}
def escalation_exhausted(stored):
"""True only when the most recent Builder limit is terminal."""
return (
stored.get("last_run_status") == "limit_reached"
and stored.get("auto_retry_attempts", 0)
>= len(orchestration.escalation_ladder_for(stored))
)
def is_family_parent(stored):
"""Recognize a family parent even after clarification cleared blockers."""
return bool(
stored.get("orchestration_kind") == "operator"
or stored.get("child_slugs")
or stored.get("depends_on")
or stored.get("blocking_children")
)
def repair_eligible(stored):
"""Keep expected/in-flight Builder escalation out of the Fixer path."""
failed_status = stored.get("last_run_status") in {
"failed", "check_failed", "deploy_failed",
}
return (
int(stored.get("fixer_attempts", 0)) < 2
and not is_family_parent(stored)
and (
stored.get("review_decision") == "planung_mit_feedback"
or stored.get("phase") in {
"planung_mit_feedback", "fixer_fehlgeschlagen", "inspector_fehlgeschlagen",
}
or failed_status
or escalation_exhausted(stored)
)
)
def available_actions(card_id, source, stored, job_active=False):
phase = stored.get("phase", "recherche_angefordert" if source == "research" else "geplant" if source == "intent" else "idea")
if job_active:
return []
actions = []
def action(id, label, phase, **extra):
actions.append({"id": id, "label": label, "phase": phase, **extra})
if phase == "archiviert":
action("reactivate", "Reactivate", "recherche_angefordert" if source == "research" else "geplant" if source == "intent" else "idea")
return actions
if source == "idea":
actions.append({"id": "plan", "label": "Refine with Planner", "endpoint": "plan"})
if source == "intent" and phase == "geplant" and not stored.get("blocked_by"):
if not stored.get("operator_generated") and SLUG_RE.fullmatch(f"{card_id}-synthese"):
action("operator", "Ask Operator", "operator_angefordert")
action("builder", "Start Builder", "in_umsetzung")
action("builder-light", "Start Builder (light)", "in_umsetzung_leicht")
if source == "research" and phase == "recherche_angefordert":
action("research", "Start Research", "recherche_laeuft")
if stored.get("failure_stage") == "deployment" and stored.get("review_decision") == "bereit_fuer_deployment" and stored.get("commit_hash"):
actions.append({"id": "retry-deployment", "label": "Retry deployment", "endpoint": "retry-deployment"})
if phase != "bereit_fuer_deployment" and not workflow.verified_deployment(stored):
action("diagnose", "Diagnose", "klaerung_reparatur", task_type="implementation_status")
action("route", "Choose next step", "klaerung_reparatur", task_type="route_next")
if repair_eligible(stored) and stored.get("failure_stage") not in {"inspector", "pre_start", "review_config", "publication", "deployment"}:
action("repair", "Repair", "klaerung_reparatur", task_type="repair")
if source == "intent" and escalation_exhausted(stored) and not stored.get("operator_generated"):
action("split", "Split", "aufteilen_angefordert")
if source == "intent" and stored.get("commit_hash") and stored.get("last_run_status") in {"failed", "check_failed"}:
actions.append({"id": "retry-review", "label": "Retry Review", "endpoint": "retry-review"})
if source == "research" and phase != "recherche_angefordert":
action("reset", "Return to research queue", "recherche_angefordert")
elif source == "intent" and phase != "geplant":
action("reset", "Return to Ready", "geplant")
action("archive", "Archive", "archiviert")
return actions
def merged_board():
with STATE_LOCK:
state = load_state()
intent_cards = list_intent_cards()
research_cards = list_research_cards()
board = []
job_active = (
repo_job_active()
if all(os.environ.get(name) for name in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR"))
else False
)
seen = set()
for slug, card in intent_cards.items():
stored = state["cards"].get(slug, {})
board.append(
{
**card,
"phase": stored.get("phase", "geplant"),
"updated_at": stored.get("updated_at"),
"last_job_id": stored.get("last_job_id"),
"last_run_status": stored.get("last_run_status"),
"last_run_at": stored.get("last_run_at"),
"review_decision": stored.get("review_decision"),
"review_report": stored.get("review_report"),
"commit_hash": stored.get("commit_hash"),
"builder_report": stored.get("builder_report"),
"failure_stage": stored.get("failure_stage"),
"failure_detail": stored.get("failure_detail"),
"research_report": stored.get("research_report"),
"parent_id": stored.get("parent_id"),
"child_slugs": stored.get("child_slugs"),
"depends_on": stored.get("depends_on"),
"synthesis_generated": stored.get("synthesis_generated", False),
"operator_decision": stored.get("operator_decision"),
"operator_reason": stored.get("operator_reason"),
"human_action": stored.get("human_action"),
"operator_parent_id": stored.get("operator_parent_id"),
"blocking_children": stored.get("blocking_children"),
"blocked_by": stored.get("blocked_by"),
"wave": stored.get("wave"),
"recommended_lane": stored.get("recommended_lane"),
"recommended_profile": stored.get("recommended_profile"),
"lane": stored.get("lane"),
"runtime": stored.get("runtime"),
"model": stored.get("model"),
"reasoning_effort": stored.get("reasoning_effort"),
"synthesis_status": stored.get("synthesis_status"),
"write_scope": stored.get("write_scope"),
"acceptance_criteria": stored.get("acceptance_criteria"),
"metrics_summary": metrics.summary(stored),
"pipeline_run_id": stored.get("pipeline_run_id"),
"auto_retry_attempts": stored.get("auto_retry_attempts", 0),
"escalation_attempts": stored.get("escalation_attempts", []),
"iteration_limit": stored.get("iteration_limit"),
"available_actions": available_actions(slug, "intent", stored, job_active),
"fixer_attempts": stored.get("fixer_attempts", 0),
"fixer_question": stored.get("fixer_question"),
"fixer_qa": stored.get("fixer_qa", []),
"fixer_outcome": stored.get("fixer_outcome"),
"fixer_target_phase": stored.get("fixer_target_phase"),
"clarification_task_type": stored.get("clarification_task_type"),
"clarification_question": stored.get("clarification_question") or stored.get("fixer_question"),
"repo_status": stored.get("repo_status"),
"repo_evidence": stored.get("repo_evidence"),
"deployment_evidence": stored.get("deployment_evidence"),
"routing_recommendation": stored.get("routing_recommendation"),
"routing_reason": stored.get("routing_reason"),
"routing_pending": stored.get("routing_pending", False),
"deployment_status": stored.get("deployment_status"),
"source_missing": False,
}
)
seen.add(slug)
for slug, card in research_cards.items():
stored = state["cards"].get(slug, {})
board.append({
**card,
"phase": stored.get("phase", "recherche_angefordert"),
"updated_at": stored.get("updated_at"),
"last_job_id": stored.get("last_job_id"),
"last_run_status": stored.get("last_run_status"),
"last_run_at": stored.get("last_run_at"),
"failure_stage": stored.get("failure_stage"),
"failure_detail": stored.get("failure_detail"),
"research_report": stored.get("research_report"),
"parent_id": stored.get("parent_id"),
"depends_on": stored.get("depends_on"),
"synthesis_generated": stored.get("synthesis_generated", False),
"metrics_summary": metrics.summary(stored),
"pipeline_run_id": stored.get("pipeline_run_id"),
"auto_retry_attempts": stored.get("auto_retry_attempts", 0),
"escalation_attempts": stored.get("escalation_attempts", []),
"iteration_limit": stored.get("iteration_limit"),
"available_actions": available_actions(slug, "research", stored, job_active),
"fixer_attempts": stored.get("fixer_attempts", 0),
"fixer_question": stored.get("fixer_question"),
"fixer_qa": stored.get("fixer_qa", []),
"fixer_target_phase": stored.get("fixer_target_phase"),
"clarification_task_type": stored.get("clarification_task_type"),
"clarification_question": stored.get("clarification_question") or stored.get("fixer_question"),
"routing_recommendation": stored.get("routing_recommendation"),
"routing_reason": stored.get("routing_reason"),
"routing_pending": stored.get("routing_pending", False),
"source_missing": False,
})
seen.add(slug)
for card_id, stored in state["cards"].items():
if card_id in seen:
continue
fallback = stored_intent_card(card_id, stored)
if fallback:
board.append(
{
**fallback,
"phase": stored.get("phase", "geplant"),
"updated_at": stored.get("updated_at"),
"last_job_id": stored.get("last_job_id"),
"last_run_status": stored.get("last_run_status"),
"last_run_at": stored.get("last_run_at"),
"review_decision": stored.get("review_decision"),
"review_report": stored.get("review_report"),
"commit_hash": stored.get("commit_hash"),
"builder_report": stored.get("builder_report"),
"failure_stage": stored.get("failure_stage"),
"failure_detail": stored.get("failure_detail"),
"parent_id": stored.get("parent_id"),
"child_slugs": stored.get("child_slugs"),
"depends_on": stored.get("depends_on"),
"synthesis_generated": stored.get("synthesis_generated", False),
"operator_decision": stored.get("operator_decision"),
"operator_reason": stored.get("operator_reason"),
"human_action": stored.get("human_action"),
"operator_parent_id": stored.get("operator_parent_id"),
"blocking_children": stored.get("blocking_children"),
"blocked_by": stored.get("blocked_by"),
"wave": stored.get("wave"),
"recommended_lane": stored.get("recommended_lane"),
"recommended_profile": stored.get("recommended_profile"),
"lane": stored.get("lane"),
"runtime": stored.get("runtime"),
"model": stored.get("model"),
"reasoning_effort": stored.get("reasoning_effort"),
"synthesis_status": stored.get("synthesis_status"),
"write_scope": stored.get("write_scope"),
"acceptance_criteria": stored.get("acceptance_criteria"),
"metrics_summary": metrics.summary(stored),
"pipeline_run_id": stored.get("pipeline_run_id"),
"auto_retry_attempts": stored.get("auto_retry_attempts", 0),
"escalation_attempts": stored.get("escalation_attempts", []),
"iteration_limit": stored.get("iteration_limit"),
"available_actions": available_actions(card_id, "intent", stored, job_active),
"fixer_attempts": stored.get("fixer_attempts", 0),
"fixer_question": stored.get("fixer_question"),
"fixer_qa": stored.get("fixer_qa", []),
"fixer_target_phase": stored.get("fixer_target_phase"),
}
)
continue
if stored.get("source") == "draft":
board.append(
{
"id": card_id,
"source": "draft",
"title": stored.get("title", card_id),
"phase": stored.get("phase", "entwurf_laeuft"),
"updated_at": stored.get("updated_at"),
"last_run_status": stored.get("last_run_status"),
"draft_conversation": stored.get("draft_conversation", []),
"failure_detail": stored.get("failure_detail"),
"available_actions": [],
}
)
continue
if stored.get("source") != "idea":
continue
board.append(
{
"id": card_id,
"source": "idea",
"title": stored.get("title", card_id),
"phase": stored.get("phase", "idea"),
"updated_at": stored.get("updated_at"),
"available_actions": available_actions(card_id, "idea", stored, job_active),
"fixer_attempts": stored.get("fixer_attempts", 0),
"fixer_question": stored.get("fixer_question"),
"fixer_qa": stored.get("fixer_qa", []),
"fixer_target_phase": stored.get("fixer_target_phase"),
}
)
active_slugs = orchestration.active_slugs()
for card in board:
stored = state["cards"].get(card["id"], {})
for key in ("inspector_result", "repo_status", "repo_evidence", "deployment_evidence",
"deployment_status", "deployed_commit_hash", "activated_system",
"draft_conversation", "draft_origin", "attempt_history", "review_history",
"clarification_qa", "clarification_required_action", "deployment_error", "preserved_worktree"):
card[key] = stored.get(key)
for card in board:
stored = state["cards"].get(card["id"], {})
active = card["id"] in active_slugs
if card["source"] != "draft":
card["available_actions"] = available_actions(card["id"], card["source"], {**stored, "phase": card["phase"]}, active)
children = [child for child in board if child.get("parent_id") == card["id"]]
card["display"] = workflow.presentation({**stored, **card}, children, active)
return board
SMOKE_TEST_DIRECT_TEMPLATE = """\
# Intent: Operator Smoke-Test {n} (klein, direct_builder erwartet)
## Ziel
Installiere `ripgrep` systemweit auf dem NixOS-Host `blunix`. Dies ist ein
kleiner, präziser Auftrag, den der Operator als `direct_builder` einstufen
und automatisch in die empfohlene Builder-Pipeline schicken soll.
Diese Karte auf „Bereit für Builder" anlegen und dann in die
Operator-Spalte ziehen (`operator_angefordert`).
## Auftrag an den Builder
- Ergänze ausschließlich `ripgrep` in der bestehenden
`environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages.nix`.
- Sortiere den neuen Eintrag alphabetisch in die Liste ein.
- Ändere keine andere Datei und nimm keine weiteren Paket- oder
Konfigurationsänderungen vor.
## Erwartetes Ergebnis
Operator liefert `direct_builder`, ein Builder-Lauf erzeugt genau einen
Commit, der ausschließlich `ripgrep` zu den systemweiten Paketen von
`blunix` hinzufügt, danach normaler Review-Übergang.
Dieser Intent ist ausschließlich ein technischer Pipeline-Test.
"""
SMOKE_TEST_SPLIT_TEMPLATE = """\
# Intent: Operator Smoke-Test {n} (groß, split erwartet)
## Ziel
Installiere `ripgrep` und `fd` systemweit auf dem NixOS-Host `blunix`.
Der Operator soll diesen absichtlich aus zwei unabhängigen Paketänderungen
bestehenden Auftrag als `split` einstufen, exakt zwei Kinder automatisch
starten und anschließend die Synthese durchlaufen lassen.
Diese Karte auf „Bereit für Builder" anlegen und dann in die
Operator-Spalte ziehen (`operator_angefordert`).
## Auftrag (an den Operator, zur Zerlegung in Builder-Kinder)
Zerlege den Parent in exakt zwei vollständige Builder-Kinder mit
unterschiedlichen, um `-{n}` ergänzten Slugs. Beide Kinder dürfen in
derselben Welle (`W1`) laufen, da ihre Schreibbereiche nicht überlappen:
1. Das erste Kind ergänzt ausschließlich `ripgrep`, alphabetisch
einsortiert, in der bestehenden `environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages.nix`. Sein `write_scope` enthält
genau diesen Pfad.
2. Das zweite Kind ergänzt ausschließlich `fd`, alphabetisch einsortiert,
in der bestehenden `environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages-stable.nix`. Sein `write_scope`
enthält genau diesen Pfad.
Jedes Kind ändert nur seine genannte Datei und nimmt keine weiteren
Paket- oder Konfigurationsänderungen vor. Zwischen den Kindern besteht keine
Abhängigkeit (`blocked_by` jeweils leer).
## Erwartetes Ergebnis
Operator liefert `split` mit exakt zwei Kindern. Beide starten automatisch;
nach erfolgreichem Review beider Kinder erzeugt das Board die Synthese, die
selbst automatisch durchläuft. Das resultierende System installiert sowohl
`ripgrep` als auch `fd` systemweit auf `blunix`.
Dieser Intent ist ausschließlich ein technischer Pipeline-Test.
"""
SMOKE_TEST_REMOVE_DIRECT_TEMPLATE = """\
# Intent: Operator Deinstallations-Smoke-Test {n} (klein, direct_builder erwartet)
## Ziel
Deinstalliere `ripgrep` systemweit vom NixOS-Host `blunix`. Dies ist das
genaue Gegenstück zum kleinen Installations-Smoke-Test und soll vom Operator
als `direct_builder` eingestuft und automatisch in die empfohlene
Builder-Pipeline geschickt werden.
Diese Karte auf „Bereit für Builder" anlegen und dann in die
Operator-Spalte ziehen (`operator_angefordert`).
## Auftrag an den Builder
- Entferne ausschließlich `ripgrep` aus der bestehenden
`environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages.nix`.
- Ändere keine andere Datei und nimm keine weiteren Paket- oder
Konfigurationsänderungen vor.
## Erwartetes Ergebnis
Operator liefert `direct_builder`, ein Builder-Lauf erzeugt genau einen
Commit, der ausschließlich `ripgrep` aus den systemweiten Paketen von
`blunix` entfernt, danach normaler Review-Übergang.
Dieser Intent ist ausschließlich ein technischer Pipeline-Test.
"""
SMOKE_TEST_REMOVE_SPLIT_TEMPLATE = """\
# Intent: Operator Deinstallations-Smoke-Test {n} (groß, split erwartet)
## Ziel
Deinstalliere `ripgrep` und `fd` systemweit vom NixOS-Host `blunix`. Dies ist
das genaue Gegenstück zum großen Installations-Smoke-Test. Der Operator soll
den Auftrag als `split` einstufen, exakt zwei Kinder automatisch starten und
anschließend die Synthese durchlaufen lassen.
Diese Karte auf „Bereit für Builder" anlegen und dann in die
Operator-Spalte ziehen (`operator_angefordert`).
## Auftrag (an den Operator, zur Zerlegung in Builder-Kinder)
Zerlege den Parent in exakt zwei vollständige Builder-Kinder mit
unterschiedlichen, um `-{n}` ergänzten Slugs. Beide Kinder dürfen in
derselben Welle (`W1`) laufen, da ihre Schreibbereiche nicht überlappen:
1. Das erste Kind entfernt ausschließlich `ripgrep` aus der bestehenden
`environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages.nix`. Sein `write_scope` enthält
genau diesen Pfad.
2. Das zweite Kind entfernt ausschließlich `fd` aus der bestehenden
`environment.systemPackages`-Liste in
`hosts/blunix/localServices/packages-stable.nix`. Sein `write_scope`
enthält genau diesen Pfad.
Jedes Kind ändert nur seine genannte Datei und nimmt keine weiteren
Paket- oder Konfigurationsänderungen vor. Zwischen den Kindern besteht keine
Abhängigkeit (`blocked_by` jeweils leer).
## Erwartetes Ergebnis
Operator liefert `split` mit exakt zwei Kindern. Beide starten automatisch;
nach erfolgreichem Review beider Kinder erzeugt das Board die Synthese, die
selbst automatisch durchläuft. Das resultierende System installiert weder
`ripgrep` noch `fd` systemweit auf `blunix`.
Dieser Intent ist ausschließlich ein technischer Pipeline-Test.
"""
SMOKE_TEST_KINDS = {
"direct": ("board-operator-smoke-test-direct", SMOKE_TEST_DIRECT_TEMPLATE),
"split": ("board-operator-smoke-test-split", SMOKE_TEST_SPLIT_TEMPLATE),
"remove-direct": (
"board-operator-smoke-test-remove-direct",
SMOKE_TEST_REMOVE_DIRECT_TEMPLATE,
),
"remove-split": (
"board-operator-smoke-test-remove-split",
SMOKE_TEST_REMOVE_SPLIT_TEMPLATE,
),
}
def create_smoke_test(kind):
"""Write a fresh, numbered install or removal smoke-test intent.
The board exposes direct_builder and split variants of both operations.
Separate prefixes prevent collisions between kinds; for split tests, the
Operator derives fresh child slugs from the numbered intent text.
"""
if kind not in SMOKE_TEST_KINDS:
raise ValueError("unbekannte Smoke-Test-Art")
prefix, template = SMOKE_TEST_KINDS[kind]
intent_dir = Path(os.environ["BOARD_INTENT_DIR"])
intent_dir.mkdir(parents=True, exist_ok=True)
existing = {int(m.group(1)) for name in intent_dir.glob(f"{prefix}-*.md") if (m := re.fullmatch(rf"{prefix}-(\d+)\.md", name.name))}
n = max(existing, default=0) + 1
slug = f"{prefix}-{n}"
(intent_dir / f"{slug}.md").write_text(template.format(n=n), encoding="utf-8")
return slug
SMOKE_TEST_MARKER_RE = re.compile(r"smoke", re.IGNORECASE)
def _smoke_test_directories():
repo_root = Path(os.environ["BOARD_REPO_DIR"])
return {
"docs/intent": Path(os.environ["BOARD_INTENT_DIR"]),
"docs/research-intent": Path(os.environ["BOARD_RESEARCH_INTENT_DIR"]),
"docs/review": repo_root / "docs" / "review",
"docs/research": repo_root / "docs" / "research",
}
def _expand_card_family(seeds, cards):
"""Follow parent/child/synthesis links outward from seeds until the
selection stops growing -- a smoke test's split children, fan-out
children and generated synthesis card never carry "smoke" in their own
slug or title, so a name match alone would leave them behind.
"""
selected = set(seeds)
changed = True
while changed:
changed = False
for card_id, card in cards.items():
refs = set(card.get("depends_on") or []) | set(card.get("child_slugs") or [])
refs.update(
ref for ref in (
card.get("parent_id"),
card.get("operator_parent_id"),
card.get("synthesis_for"),
)
if ref
)
if card_id in selected or refs & selected:
before = len(selected)
selected.add(card_id)
selected.update(ref for ref in refs if ref in cards)
if len(selected) > before:
changed = True
return selected
def delete_smoke_test_cards():
"""Remove every smoke-test card, and its whole linked family (split
children, fan-out children, a generated synthesis card), from the
board at once.
This is the same cleanup an operator previously had to script by hand
after a round of pipeline smoke testing, now reachable from the board
itself. Refuses while any board job is queued or running -- the same
repo_job_active() guard research transitions already use -- so this
can never delete an intent file out from under a job that is still
reading it. Backing docs/intent, docs/research-intent, docs/review and
docs/research files are deleted outright: these are throwaway
pipeline-test artifacts, not files anyone edits by hand, and the
calling UI confirms before sending this request.
"""
if repo_job_active():
raise PermissionError("Ein Board-Job läuft oder ist eingereiht")
directories = _smoke_test_directories()
with STATE_LOCK:
state = load_state()
cards = state["cards"]
seeds = {
card_id for card_id, card in cards.items()
if SMOKE_TEST_MARKER_RE.search(f"{card_id} {card.get('title', '')}")
}
for directory in directories.values():
if directory.is_dir():
seeds.update(
path.stem for path in directory.glob("*.md")
if SMOKE_TEST_MARKER_RE.search(path.stem)
)
selected = _expand_card_family(seeds, cards)
removed_files = 0
for directory in directories.values():
if not directory.is_dir():
continue
directory_resolved = directory.resolve(strict=True)
for slug in selected:
if not SLUG_RE.fullmatch(slug):
continue
candidate = directory / f"{slug}.md"
if not candidate.is_file():
continue
resolved = candidate.resolve(strict=True)
try:
resolved.relative_to(directory_resolved)
except ValueError:
continue
if resolved != directory_resolved / f"{slug}.md":
continue
try:
resolved.unlink()
removed_files += 1
except OSError as error:
# A single unwritable file (e.g. a sandbox directory
# missing from this service's ReadWritePaths) must not
# take the whole request down -- verified live
# 2026-09-10: an uncaught OSError here killed the
# connection mid-response, which Traefik then surfaced
# as a 502. State/telemetry cleanup below still runs
# for every selected slug regardless.
print(f"Smoke-Test-Datei konnte nicht entfernt werden ({resolved}): {error}", file=sys.stderr)
removed_cards = sum(1 for slug in selected if cards.pop(slug, None) is not None)
save_state(state)
try:
telemetry.delete_slugs(Path(os.environ["BOARD_TELEMETRY_DB"]), selected)
except (OSError, ValueError) as error:
print(f"Telemetrie-Bereinigung nach Smoke-Test-Löschung fehlgeschlagen: {error}", file=sys.stderr)
return {"removed_cards": removed_cards, "removed_files": removed_files}
def create_idea(title):
title = title.strip()
if not title or len(title) > 200:
raise ValueError("invalid title")
card_id = "idea-" + uuid.uuid4().hex[:12]
with STATE_LOCK:
state = load_state()
state["cards"][card_id] = {
"source": "idea",
"title": title,
"phase": "idea",
"updated_at": now_iso(),
}
save_state(state)
return card_id
def delete_manual_card(card_id):
"""Delete a card that lives only in state.json -- a manual "idea"
backlog card, or an abandoned interactive-intake "draft" that never
finished. A *finished* draft is no longer a "draft" source by the
time this could run: record_draft_intent() replaces it with a normal
"geplant" intent card, so this path never touches it."""
with STATE_LOCK:
state = load_state()
card = state["cards"].get(card_id)
if not card or card.get("source") not in ("idea", "draft"):
raise LookupError("not a manually created card")
del state["cards"][card_id]
save_state(state)
MAX_DRAFT_MESSAGE_LENGTH = 4000
MAX_DRAFT_CONVERSATION_TURNS = 40
def _queue_draft_job(state, card_id, conversation):
"""Write a job_type="draft" job and update the card's own state entry
in place. Assumes the caller already holds STATE_LOCK and has already
called load_state() into `state` -- same pure state-mutation-helper
pattern as _queue_clarification() above. Shared by create_draft()
(the opening turn), answer_draft() and retry_draft() (a follow-up
turn) below.
"""
stored = state["cards"].setdefault(card_id, {})
job_id = write_job(
card_id, stored.get("title", card_id), "", job_type="draft",
conversation=conversation,
)
stored.update(
source="draft", phase="entwurf_laeuft", last_job_id=job_id,
last_run_status="queued_draft", draft_conversation=conversation,
updated_at=now_iso(),
)
return job_id
def create_draft(topic):
"""Start a new interactive Planner intake dialogue: a draft card with
no backing file yet, its opening turn queued immediately.
Deliberately no repo_job_active() guard, unlike research/fixer
transitions: an ordinary draft turn never touches the shared checkout
(see worker.py's run_draft()), so it can run alongside a Builder/
Review/Research/Split job without contending for anything. Only the
single narrow moment where a finished dialogue writes its new intent
file takes the repo lock -- see worker.py's job_type == "draft"
handling.
"""
topic = str(topic or "").strip()
if not topic or len(topic) > 200:
raise ValueError("ein Thema mit maximal 200 Zeichen ist erforderlich")
card_id = "draft-" + uuid.uuid4().hex[:12]
conversation = [{"role": "user", "text": topic}]
with STATE_LOCK:
state = load_state()
state["cards"][card_id] = {"title": topic}
_queue_draft_job(state, card_id, conversation)
save_state(state)
return card_id
def plan_card(card_id):
"""Refine an idea in place, preserving its identity."""
with STATE_LOCK:
state = load_state()
stored = state["cards"].get(card_id, {})
if stored.get("source") != "idea" or stored.get("phase") == "archiviert":
raise PermissionError("Only active ideas can start this planning conversation.")
_queue_draft_job(state, card_id, [{"role": "user", "text": stored["title"]}])
save_state(state)
return card_id
def retry_deployment(card_id):
with STATE_LOCK:
state = load_state()
card = state["cards"].get(card_id, {})
if (card.get("phase") == "archiviert" or card.get("failure_stage") != "deployment"
or card.get("review_decision") != "bereit_fuer_deployment" or not card.get("commit_hash")
or card_id in orchestration.active_slugs()):
raise PermissionError("Only an inactive, failed deployment of an approved commit can be retried.")
job_id = write_job(card_id, card.get("title", card_id), card.get("intent_path", ""),
job_type="retry_deployment", commit_hash=card["commit_hash"],
pipeline_run_id=card.get("pipeline_run_id"))
card.update(last_job_id=job_id, last_run_status="queued", updated_at=now_iso())
save_state(state)
return job_id
def answer_draft(card_id, answer):
"""Answer the Planner's latest draft question and queue the next turn."""
with STATE_LOCK:
state = load_state()
stored = state["cards"].get(card_id)
if not stored or stored.get("source") != "draft":
raise LookupError("unknown draft card")
if stored.get("phase") != "entwurf_frage":
raise PermissionError("Antwort ist nur bei einer offenen Rückfrage möglich")
answer = str(answer or "").strip()
if not answer or len(answer) > MAX_DRAFT_MESSAGE_LENGTH:
raise ValueError(f"eine Antwort mit maximal {MAX_DRAFT_MESSAGE_LENGTH} Zeichen ist erforderlich")
conversation = [*(stored.get("draft_conversation") or []), {"role": "user", "text": answer}]
if len(conversation) > MAX_DRAFT_CONVERSATION_TURNS:
raise PermissionError(
"Konversation ist zu lang -- als Intent per Hand anlegen oder neu beginnen"
)
job_id = _queue_draft_job(state, card_id, conversation)
save_state(state)
return job_id
def retry_draft(card_id):
"""Re-run the last Planner turn unchanged -- for a draft whose last
turn failed (a runtime error, a malformed response, a slug collision
at finalization), since a retry needs no new operator input."""
with STATE_LOCK:
state = load_state()
stored = state["cards"].get(card_id)
if not stored or stored.get("source") != "draft":
raise LookupError("unknown draft card")
if stored.get("phase") != "entwurf_fehlgeschlagen":
raise PermissionError("Erneuter Versuch ist nur nach einem fehlgeschlagenen Lauf möglich")
conversation = stored.get("draft_conversation") or []
job_id = _queue_draft_job(state, card_id, conversation)
save_state(state)
return job_id
def read_review_report(slug):
"""Read only the fixed report belonging to an existing intent card.
The request contributes a validated slug, never a filesystem path. The
state entry must contain the exact path the worker creates, and resolving
that path must keep it inside this repository's docs/review directory.
"""
if not SLUG_RE.fullmatch(slug) or slug not in list_intent_cards():
raise LookupError("unknown intent card")
expected_path = f"docs/review/{slug}.md"
with STATE_LOCK:
stored = load_state()["cards"].get(slug, {})
if stored.get("review_report") != expected_path:
raise LookupError("review report not found")
repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True)
review_root = (repo_root / "docs" / "review").resolve(strict=True)
report_path = repo_root / expected_path
resolved_report = report_path.resolve(strict=True)
try:
review_root.relative_to(repo_root)
resolved_report.relative_to(review_root)
except ValueError as error:
raise LookupError("invalid review report path") from error
if resolved_report != review_root / f"{slug}.md":
raise LookupError("invalid review report path")
return {"path": expected_path, "content": resolved_report.read_text(encoding="utf-8")}
def read_research_report(slug):
"""Read only the fixed research report belonging to a research card."""
if not SLUG_RE.fullmatch(slug) or slug not in list_research_cards():
raise LookupError("unknown research card")
expected_path = f"docs/research/{slug}.md"
with STATE_LOCK:
stored = load_state()["cards"].get(slug, {})
if stored.get("research_report") != expected_path:
raise LookupError("research report not found")
repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True)
research_root = (repo_root / "docs" / "research").resolve(strict=True)
resolved_report = (repo_root / expected_path).resolve(strict=True)
try:
research_root.relative_to(repo_root)
resolved_report.relative_to(research_root)
except ValueError as error:
raise LookupError("invalid research report path") from error
if resolved_report != research_root / f"{slug}.md":
raise LookupError("invalid research report path")
return {"path": expected_path, "content": resolved_report.read_text(encoding="utf-8")}
def read_research_intent(slug):
"""Read the validated source document belonging to a research card."""
if not SLUG_RE.fullmatch(slug) or slug not in list_research_cards():
raise LookupError("unknown research card")
expected_path = f"docs/research-intent/{slug}.md"
repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True)
intent_root = (repo_root / "docs" / "research-intent").resolve(strict=True)
resolved_intent = (repo_root / expected_path).resolve(strict=True)
try:
intent_root.relative_to(repo_root)
resolved_intent.relative_to(intent_root)
except ValueError as error:
raise LookupError("invalid research intent path") from error
if resolved_intent != intent_root / f"{slug}.md":
raise LookupError("invalid research intent path")
return {"path": expected_path, "content": resolved_intent.read_text(encoding="utf-8")}
def read_intent(slug):
"""Read the intent document belonging to an existing intent card."""
if not SLUG_RE.fullmatch(slug) or slug not in list_intent_cards():
raise LookupError("unknown intent card")
expected_path = f"docs/intent/{slug}.md"
repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True)
intent_root = (repo_root / "docs" / "intent").resolve(strict=True)
intent_path = repo_root / expected_path
resolved_intent = intent_path.resolve(strict=True)
try:
resolved_intent.relative_to(intent_root)
except ValueError as error:
raise LookupError("invalid intent path") from error
if resolved_intent != intent_root / f"{slug}.md":
raise LookupError("invalid intent path")
return {"path": expected_path, "content": resolved_intent.read_text(encoding="utf-8")}
# Kept in sync by hand with the role each job-triggering phase actually
# runs (worker.py's *_PROMPT_TEMPLATEs and agent-codex/builder.py's own
# fixer/builder prompts all name their role file explicitly, e.g.
# "Du bist die durch reviewer.md definierte Reviewer-Rolle") -- this is
# purely a read-only "view the role prompt" link for the Agents column,
# not itself involved in dispatching a job.
AGENT_ROLE_FILES = {
"operator": "operator.md",
"builder": "builder.md",
"reviewer": "reviewer.md",
"researcher": "researcher.md",
"planner": "planner.md",
"inspector": "inspector.md",
"fixer": "fixer.md",
}
def read_agent_prompt(role):
"""Read a fixed agent role-prompt file from BOARD_ROLE_DIR (agents/).
Same validated-allowlist pattern as read_intent() above, just rooted at
the role directory instead of docs/intent: the request contributes a
role name checked against AGENT_ROLE_FILES, never a filesystem path.
"""
filename = AGENT_ROLE_FILES.get(role)
if not filename:
raise LookupError("unknown agent role")
role_root = Path(os.environ["BOARD_ROLE_DIR"]).resolve(strict=True)
prompt_path = (role_root / filename).resolve(strict=True)
try:
prompt_path.relative_to(role_root)
except ValueError as error:
raise LookupError("invalid role prompt path") from error
if prompt_path != role_root / filename:
raise LookupError("invalid role prompt path")
return {"path": f"agents/{filename}", "content": prompt_path.read_text(encoding="utf-8")}
def read_card_telemetry(slug):
if not SLUG_RE.fullmatch(slug):
raise LookupError("unknown card")
stored = load_state().get("cards", {}).get(slug, {})
if slug not in list_intent_cards() and slug not in list_research_cards() and not stored:
raise LookupError("unknown card")
pipeline_run_id = stored.get("pipeline_run_id")
current = (
telemetry.get_pipeline_run(Path(os.environ["BOARD_TELEMETRY_DB"]), pipeline_run_id)
if pipeline_run_id
else None
)
return {
"slug": slug,
"pipeline_run_id": pipeline_run_id,
"current_pipeline": current,
"runs": telemetry.get_slug_runs(
Path(os.environ["BOARD_TELEMETRY_DB"]), slug, limit=20
),
}
def repo_job_active():
"""True if a job is queued or currently being run by worker.py -- the
job file sits in BOARD_PROCESSING_DIR for the whole `codex exec`
duration, not just while queued, so this covers both "waiting" and
"mid-run". Used as the trigger-side guard for research jobs, which
(per docs/intent/board-builder-worktree-parallelism.md) still run
against the one shared checkout and therefore still need the full,
untargeted lock against *any* other job -- builder jobs use the
slug-aware, concurrency-limited builder_trigger_blocked() below
instead.
"""
for directory_env in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR", "AGENT_BUILDER_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR_LEICHT", "AGENT_BUILDER_PROCESSING_DIR_LEICHT"):
if not os.environ.get(directory_env):
continue
directory = Path(os.environ[directory_env])
if directory.is_dir() and any(directory.glob("*.json")):
return True
return False
def queued_jobs():
"""Parse every job currently queued or in flight (BOARD_INBOX_DIR and
BOARD_PROCESSING_DIR) into a list of dicts. Corrupt or unreadable
entries are skipped rather than raising -- a bad file here must never
block the trigger API."""
jobs = []
for directory_env in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR", "AGENT_BUILDER_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR_LEICHT", "AGENT_BUILDER_PROCESSING_DIR_LEICHT"):
if not os.environ.get(directory_env):
continue
directory = Path(os.environ[directory_env])
if not directory.is_dir():
continue
for job_path in sorted(directory.glob("*.json")):
try:
job = json.loads(job_path.read_text(encoding="utf-8"))
job["slug"] = job.get("slug") or job.get("board_slug")
if directory_env.startswith("AGENT_BUILDER"):
job.setdefault("job_type", "builder")
job.setdefault("lane", "leicht" if directory_env.endswith("LEICHT") else "schwer")
jobs.append(job)
except (OSError, ValueError):
continue
return jobs
def builder_trigger_blocked(card_id, lane="schwer"):
"""Returns an error string if a new builder job for card_id must not be
queued yet, else None.
Builder jobs alone may be queued concurrently for *different* cards --
see docs/intent/board-builder-worktree-parallelism.md for the isolated
per-job git worktrees (agent-codex/builder.py's create_worktree()/
remove_worktree(), only for the flake repo) that make that safe.
Since docs/intent/board-builder-weighted-lanes.md gave each of the two
Builder columns its own dedicated agent-codex-builder-1/-2 instance and
inbox, the cap is enforced per lane (exactly one in-flight job per
lane, matching one dedicated instance each) rather than as one shared
number -- a "schwer" and a "leicht" job may always run at the same
time, but two "schwer" (or two "leicht") jobs may not. Research/review
jobs still use the one shared checkout without any lock of their own
on the trigger side, so any of those queued or running still blocks a
new builder job entirely, same as before."""
jobs = queued_jobs()
if any(job.get("job_type", "builder") != "builder" for job in jobs):
return "a board-agent job is already queued or running against this repository"
builder_jobs = [job for job in jobs if job.get("job_type", "builder") == "builder"]
if any(job.get("slug") == card_id for job in builder_jobs):
return "a board-agent job is already queued or running for this card"
if any(job.get("lane", "schwer") == lane for job in builder_jobs):
return f"a board-agent builder job is already queued or running for lane '{lane}'"
return None
def write_job(slug, title, intent_path, job_type="builder", runtime=None, **extra):
job_id = f"{slug}-{uuid.uuid4().hex[:16]}"
inbox = Path(os.environ["BOARD_INBOX_DIR"])
inbox.mkdir(parents=True, exist_ok=True, mode=0o700)
pipeline_run_id = extra.pop("pipeline_run_id", None)
if not pipeline_run_id and os.environ.get("BOARD_STATE_FILE"):
stored = load_state().get("cards", {}).get(slug, {})
pipeline_run_id = stored.get("pipeline_run_id")
parent_slug = stored.get("operator_parent_id") or stored.get("synthesis_for")
if not pipeline_run_id and parent_slug:
pipeline_run_id = (
load_state().get("cards", {}).get(parent_slug, {}).get("pipeline_run_id")
)
pipeline_run_id = pipeline_run_id or uuid.uuid4().hex
job = {
"job_id": job_id,
"job_type": job_type,
"slug": slug,
"title": title,
"intent_path": intent_path,
"requested_at": now_iso(),
"runtime": validate_runtime(runtime or board_runtime()),
"pipeline_run_id": pipeline_run_id,
**extra,
}
state_store.write_payload(inbox / f"{job_id}.json", job)
return job_id
def builder_column_payload(column="in_umsetzung"):
"""Snapshot the Builder column settings for a queued job.
The values are read once, at trigger time, so changing the column later
cannot alter a job that is already waiting in the inbox. Empty optional
values are omitted because the worker uses its own defaults in that case.
`column` selects which of the two Builder columns
(docs/intent/board-builder-weighted-lanes.md) to snapshot; defaults to
"in_umsetzung" ("schwer") for the split-escalation call site, which is
deliberately not lane-aware.
"""
config = load_column_config()[column]
extra = {
key: config[key]
for key in ("model", "reasoning_effort")
if config.get(key)
}
extra["iteration_limit"] = config["iteration_limit"]
return config["runtime"], extra
def known_card_ids():
"""Every slug the board currently recognizes as a real card -- intent,
research, or manually created idea -- used to validate both ends of a
link_parent() call before touching fanout.py's state, which otherwise
trusts its caller to have already done this (see fanout.link_child()'s
docstring)."""
ids = set(list_intent_cards()) | set(list_research_cards())
for card_id, stored in load_state().get("cards", {}).items():
if stored.get("source") == "idea":
ids.add(card_id)
return ids
def link_parent(child_id, parent_id):
"""Validate both slugs are real, known cards, then delegate the actual
bookkeeping write to fanout.link_child() -- see
docs/intent/board-subintent-fanout-synthesis.md."""
with STATE_LOCK:
ids = known_card_ids()
if child_id not in ids:
raise LookupError("unknown card")
if parent_id not in ids:
raise LookupError("unknown parent card")
fanout.link_child(parent_id, child_id)
def retry_review(card_id, commit_hash=None):
"""Queue a fresh Review job against a card's own already-recorded
commit_hash, without a new Builder run.
Exists because a Review job can fail for reasons that have nothing to
do with the commit itself (found live 2026-09-09,
operator-smoke-split-docs and operator-smoke-split-agent-codex-2: two
independent Reviewer phrasing variants run_review()'s decision match
didn't tolerate, since fixed) -- rebuilding via the normal "geplant ->
in_umsetzung" path is the wrong recovery in that case, since Builder
correctly reports "no_changes" when its own acceptance criteria are
already met by the existing commit, and no_changes never queues a new
Review. This re-runs exactly the Review step completion_watcher.py
would have queued after that original commit.
An explicit commit_hash overrides the card's own stored one -- needed
only for a card whose Review failed *before* completion_watcher.py's
2026-09-09 fix that records commit_hash at the commit itself rather
than only on a successful Review; every card going forward has its
own stored value and never needs the override. Validated against the
same hex-digest shape agent-codex/builder.py's COMMIT_RECORD_RE
extracts, since it flows straight into run_review()'s `git show`/
`git diff` calls.
Reuses the runtime/model/reasoning_effort already stored on the card
(i.e. whatever the original Builder/Operator run actually used) rather
than the live "review" column config, so a retry reviews the same
commit under the same profile that produced it, not whatever config
happens to be set right now.
"""
if not SLUG_RE.fullmatch(card_id):
raise LookupError("unknown card")
if commit_hash is not None and not re.fullmatch(r"[0-9a-f]{7,40}", commit_hash):
raise ValueError("ungültiger commit_hash")
with STATE_LOCK:
intent_cards = list_intent_cards()
if card_id not in intent_cards:
raise LookupError("unknown intent card")
state = load_state()
stored = state["cards"].get(card_id, {})
commit_hash = commit_hash or stored.get("commit_hash")
if not commit_hash:
raise LookupError("card has no recorded commit to review")
if repo_job_active():
raise PermissionError(
"a board-agent job is already queued or running against this repository"
)
runtime = validate_runtime(stored.get("runtime") or board_runtime())
extra = {
key: stored[key]
for key in ("model", "reasoning_effort")
if stored.get(key)
}
job_id = write_job(
card_id,
intent_cards[card_id]["title"],
intent_cards[card_id]["intent_path"],
job_type="review",
runtime=runtime,
commit_hash=commit_hash,
pipeline_run_id=stored.get("pipeline_run_id") or uuid.uuid4().hex,
**extra,
)
stored.update(
{
"source": "intent",
"phase": "review",
"last_job_id": job_id,
"last_run_status": "queued_review",
"updated_at": now_iso(),
}
)
state["cards"][card_id] = stored
save_state(state)
return job_id
def _migrate_clarification_state(stored):
"""Read old Fixer cards without rewriting state merely on GET."""
migrated = dict(stored)
if "clarification_attempts" not in migrated and "fixer_attempts" in migrated:
migrated["clarification_attempts"] = migrated.get("fixer_attempts", 0)
if "clarification_question" not in migrated and "fixer_question" in migrated:
migrated["clarification_question"] = migrated.get("fixer_question")
if "clarification_qa" not in migrated and "fixer_qa" in migrated:
migrated["clarification_qa"] = migrated.get("fixer_qa", [])
return migrated
def _queue_clarification(card_id, stored, card, task_type, answer=None):
if task_type not in CLARIFICATION_TASK_TYPES:
raise ValueError("Auftragstyp muss implementation_status, route_next oder repair sein")
stored.update(_migrate_clarification_state(stored))
attempts = int(stored.get("fixer_attempts", 0))
if task_type == "repair" and answer is None:
if not repair_eligible(stored):
raise PermissionError("repair ist nur für echte Fehler oder negative Reviews erlaubt")
if task_type == "repair" and attempts >= 2:
raise PermissionError("maximal zwei Fixer-Versuche pro Karte")
qa = stored.get("clarification_qa") or stored.get("fixer_qa") or []
if answer is not None:
question = str(stored.get("clarification_question") or stored.get("fixer_question") or "").strip()
answer = str(answer or "").strip()
if not question or not answer or len(answer) > 4000:
raise ValueError("eine konkrete Antwort mit maximal 4000 Zeichen ist erforderlich")
qa = [*qa, {"question": question, "answer": answer}]
config = load_column_config()["klaerung_reparatur"]
pipeline_run_id = stored.get("pipeline_run_id") or uuid.uuid4().hex
job_id = write_job(
card_id, card["title"], card["intent_path"],
job_type="repair" if task_type == "repair" else "inspector", runtime=config["runtime"],
clarification_task_type=task_type,
pipeline_run_id=pipeline_run_id,
model=config.get("model") or None,
reasoning_effort=config.get("reasoning_effort") or None,
iteration_limit=config["iteration_limit"],
builder_report=stored.get("builder_report", ""),
failure_stage=stored.get("failure_stage", ""),
failure_detail=stored.get("failure_detail", ""),
review_report=stored.get("review_report", ""),
commit_hash=stored.get("commit_hash", ""),
original_phase=stored.get("clarification_origin_phase") or stored.get("phase", ""),
fixer_qa=qa,
fixer_attempts=attempts + (1 if task_type == "repair" else 0),
deployment_evidence=deployment_evidence(stored),
checked_revision=stored.get("commit_hash") or None,
)
stored.update(
phase="klaerung_reparatur", last_job_id=job_id,
last_run_status="queued_repair" if task_type == "repair" else "queued_inspector",
clarification_task_type=task_type,
clarification_origin_phase=stored.get("clarification_origin_phase") or stored.get("phase", ""),
fixer_attempts=attempts + (1 if task_type == "repair" else 0),
clarification_attempts=attempts + (1 if task_type == "repair" else 0),
fixer_qa=qa, clarification_qa=qa, fixer_question=None,
clarification_question=None, pipeline_run_id=pipeline_run_id,
runtime=config["runtime"], model=config.get("model", ""),
reasoning_effort=config.get("reasoning_effort", ""),
iteration_limit=config["iteration_limit"], updated_at=now_iso(),
)
return job_id
def deployment_evidence(stored):
status = stored.get("deployment_status")
if workflow.verified_deployment(stored):
normalized = "deployed"
elif status in {"deploy_failed", "not_deployed"}:
normalized = "not_deployed"
else:
normalized = "unknown"
return {
"status": normalized,
"commit_hash": stored.get("commit_hash", ""),
"deploy_job_id": stored.get("deploy_job_id", ""),
"source": "trusted board wrapper state",
}
def answer_fixer(card_id, answer):
with STATE_LOCK:
cards = {**list_intent_cards(), **list_research_cards()}
state = load_state()
stored = state["cards"].get(card_id, {})
if card_id not in cards and stored.get("source") != "idea":
raise LookupError("unknown intent card")
if stored.get("phase") not in {"fixer_rueckfrage", "inspector_rueckfrage"}:
raise PermissionError("Antwort ist nur bei einer persistenten Rückfrage erlaubt")
if repo_job_active():
raise PermissionError("a board-agent job is already queued or running")
card = cards.get(card_id) or {
"id": card_id, "source": "idea", "title": stored.get("title", card_id),
"intent_path": "",
}
task_type = stored.get("clarification_task_type") or "repair"
job_id = _queue_clarification(card_id, stored, card, task_type, answer=answer)
state["cards"][card_id] = stored
save_state(state)
return job_id
def apply_routing_recommendation(card_id):
"""Apply only an Inspector recommendation explicitly confirmed by the user."""
with STATE_LOCK:
state = load_state()
stored = state.get("cards", {}).get(card_id, {})
action = stored.get("routing_recommendation")
if not stored.get("routing_pending") or action not in ROUTING_ACTION_PHASES:
raise PermissionError("keine bestätigbare Routing-Empfehlung vorhanden")
if action == "review":
retry_review(card_id)
elif action in {"in_umsetzung", "in_umsetzung_leicht"} and stored.get("phase") != "geplant":
update_phase(card_id, "geplant")
update_phase(card_id, action)
else:
update_phase(card_id, action)
state = load_state()
stored = state["cards"][card_id]
stored.update(routing_pending=False, routing_applied_at=now_iso(), updated_at=now_iso())
state["cards"][card_id] = stored
save_state(state)
return action
def update_phase(card_id, new_phase, task_type=None):
if new_phase not in PHASES and new_phase not in RESEARCH_PHASES:
raise ValueError("invalid phase")
intent_cards = list_intent_cards()
research_cards = list_research_cards()
with STATE_LOCK:
state = load_state()
is_intent = card_id in intent_cards
is_research = card_id in research_cards
stored = state["cards"].get(card_id, {})
if not is_intent and not is_research and stored.get("source") != "idea":
raise LookupError("unknown card")
current_phase = stored.get("phase", "recherche_angefordert" if is_research else ("geplant" if is_intent else "idea"))
job_id = None
if new_phase in {"deployed", "bereit_fuer_deployment", "recherche_fertig"}:
raise PermissionError("This state requires a verified worker result, not a manual move.")
if card_id in orchestration.active_slugs():
raise PermissionError("This card has queued or active work; wait for its result.")
if new_phase == "review":
return retry_review(card_id)
if new_phase == "archiviert":
descendants = set(stored.get("child_slugs") or stored.get("depends_on") or [])
if descendants & orchestration.active_slugs():
raise PermissionError("Active children must finish before this family can be archived.")
stored.update(phase="archiviert", updated_at=now_iso())
state["cards"][card_id] = stored
save_state(state)
return None
reset_phase = "recherche_angefordert" if is_research else "geplant" if is_intent else "idea"
if new_phase == reset_phase:
if is_research and current_phase not in RESEARCH_PHASES and current_phase not in {
"klaerung_reparatur", "inspector_laeuft", "inspector_rueckfrage", "inspector_fehlgeschlagen",
}:
raise PermissionError("Research card has an invalid stored phase.")
history = stored.setdefault("attempt_history", [])
if stored.get("last_job_id"):
history.append({key: stored.get(key) for key in ("last_job_id", "last_run_status", "last_run_at", "commit_hash", "review_decision", "builder_report", "failure_detail", "pipeline_run_id")})
del history[:-100]
for key in ("last_run_status", "failure_stage", "failure_detail", "clarification_origin_phase",
"routing_pending", "operator_dispatch_state", "review_decision", "review_report",
"auto_retry_attempts", "deployment_status", "deployed_commit_hash", "activated_system",
"deploy_job_id", "expected_builder_job_id"):
stored.pop(key, None)
stored.update(phase=reset_phase, updated_at=now_iso())
state["cards"][card_id] = stored
save_state(state)
return None
fixer_phases = {
"fixer_angefordert", "fixer_laeuft", "fixer_rueckfrage", "fixer_fehlgeschlagen",
"klaerung_reparatur", "inspector_laeuft", "inspector_rueckfrage",
"inspector_fehlgeschlagen",
}
if new_phase in {"fixer_angefordert", "klaerung_reparatur"}:
if repo_job_active():
raise PermissionError("a board-agent job is already queued or running")
card = intent_cards.get(card_id) or research_cards.get(card_id) or {
"id": card_id, "source": "idea", "title": stored.get("title", card_id),
"intent_path": "",
}
job_id = _queue_clarification(
card_id, stored, card, task_type or ("repair" if new_phase == "fixer_angefordert" else "")
)
stored["source"] = card["source"]
state["cards"][card_id] = stored
save_state(state)
return job_id
if new_phase in fixer_phases:
raise PermissionError("diese Fixer-Phase wird nur automatisch gesetzt")
if current_phase in fixer_phases:
if current_phase == "fixer_rueckfrage":
if new_phase == "review":
# A human may send a question-blocked card to Review for
# inspection without answering or discarding the
# persistent Fixer question.
stored["phase"] = "review"
stored["updated_at"] = now_iso()
state["cards"][card_id] = stored
save_state(state)
return None
raise PermissionError("Rückfrage über das Antwortfeld beantworten")
if new_phase != "geplant":
raise PermissionError("Fixer-Ergebnis muss zuerst nach 'geplant' zurückgesetzt werden")
stored["phase"] = "geplant"
stored["updated_at"] = now_iso()
state["cards"][card_id] = stored
save_state(state)
return None
if is_research:
if current_phase not in RESEARCH_PHASES:
raise PermissionError("research card has an invalid stored phase")
if new_phase == "archiviert" or current_phase == "archiviert":
if new_phase == "recherche_laeuft":
raise PermissionError("archived cards must move to 'recherche_angefordert' before 'recherche_laeuft'")
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
stored["source"] = "research"
state["cards"][card_id] = stored
save_state(state)
fanout.maybe_synthesize(card_id)
return None
if new_phase not in RESEARCH_PHASES:
raise PermissionError("research cards require research phases")
if RESEARCH_PHASE_RANK[new_phase] > RESEARCH_PHASE_RANK[current_phase] and RESEARCH_PHASE_RANK[new_phase] - RESEARCH_PHASE_RANK[current_phase] > 1:
raise PermissionError("forward transition skips a research stage")
if new_phase == "recherche_laeuft":
if current_phase != "recherche_angefordert":
raise PermissionError("trigger only allowed from 'recherche_angefordert'")
if repo_job_active():
raise PermissionError("a board-agent job is already queued or running against this repository")
config = load_column_config()["recherche_laeuft"]
extra = {
key: config[key]
for key in ("model", "reasoning_effort")
if config.get(key)
}
runtime = config["runtime"]
pipeline_run_id = uuid.uuid4().hex
job_id = write_job(card_id, research_cards[card_id]["title"], research_cards[card_id]["intent_path"], job_type="research", runtime=runtime, research_intent_path=research_cards[card_id]["intent_path"], pipeline_run_id=pipeline_run_id, **extra)
stored.update({"source": "research", "last_job_id": job_id, "last_run_status": "queued", "runtime": runtime, "pipeline_run_id": pipeline_run_id})
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
stored["source"] = "research"
state["cards"][card_id] = stored
save_state(state)
# Covers a card manually dragged straight to recherche_fertig/
# -fehlgeschlagen, not just the automatic worker.py completion
# path -- see docs/intent/board-subintent-fanout-synthesis.md.
fanout.maybe_synthesize(card_id)
return job_id
# Archiving is an explicit bookkeeping escape hatch. It is allowed
# from every phase and never queues a pipeline job. Reactivation must
# go through geplant before the normal builder trigger can be used.
if current_phase == "archiviert" and new_phase in ("in_umsetzung", "in_umsetzung_leicht"):
raise PermissionError(
"archived cards must move to 'geplant' before 'in_umsetzung'"
)
if new_phase == "archiviert" or current_phase == "archiviert":
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
if is_intent:
stored["source"] = "intent"
state["cards"][card_id] = stored
save_state(state)
fanout.maybe_synthesize(card_id)
return None
# The Operator request is the only manually reachable Operator
# transition. The remaining phases are worker/scheduler outcomes.
operator_phases = {
"operator_angefordert", "operator_laeuft", "wartet_auf_kinder",
"durch_kinder_blockiert", "operator_klaerung_noetig",
}
if new_phase == "operator_angefordert":
if not is_intent:
raise PermissionError("nur Intent-Karten können vom Operator analysiert werden")
if stored.get("operator_generated"):
raise PermissionError("Operator-Kinder dürfen nicht rekursiv analysiert werden")
if current_phase != "geplant":
raise PermissionError("Operator-Trigger ist nur aus 'geplant' erlaubt")
if not SLUG_RE.fullmatch(f"{card_id}-synthese"):
raise PermissionError("Karten-Slug ist für einen Synthese-Intent zu lang")
if repo_job_active():
raise PermissionError(
"a board-agent job is already queued or running against this repository"
)
config = load_column_config()["operator_angefordert"]
extra = {
key: config[key]
for key in ("model", "reasoning_effort")
if config.get(key)
}
pipeline_run_id = uuid.uuid4().hex
job_id = write_job(
card_id,
intent_cards[card_id]["title"],
intent_cards[card_id]["intent_path"],
job_type="operator",
runtime=config["runtime"],
builder_profiles=orchestration.configured_profiles(load_column_config()),
pipeline_run_id=pipeline_run_id,
**extra,
)
# A card may be sent to the Operator after an earlier Builder or
# Review attempt. Those fields describe the old pipeline and must
# not be rendered as the outcome of this newly queued Operator
# run (notably an old iteration-limit report).
for stale_field in (
"lane",
"builder_report",
"auto_retry_attempts",
"commit_hash",
"review_decision",
"review_report",
"failure_stage",
"failure_detail",
"last_run_at",
"operator_decision",
"operator_reason",
"human_action",
):
stored.pop(stale_field, None)
stored.update(
{
"source": "intent",
"title": intent_cards[card_id]["title"],
"phase": new_phase,
"last_job_id": job_id,
"last_run_status": "queued_operator",
"runtime": config["runtime"],
"model": config["model"],
"reasoning_effort": config["reasoning_effort"],
"pipeline_run_id": pipeline_run_id,
"updated_at": now_iso(),
}
)
state["cards"][card_id] = stored
save_state(state)
return job_id
if new_phase in operator_phases:
raise PermissionError("diese Operator-Phase wird nur automatisch gesetzt")
if current_phase in operator_phases:
if new_phase != "geplant":
raise PermissionError("Operator-Ergebnis muss zuerst nach 'geplant' zurückgesetzt werden")
stored["phase"] = "geplant"
stored["updated_at"] = now_iso()
state["cards"][card_id] = stored
save_state(state)
return None
# Split escalation column -- see
# docs/intent/board-builder-limit-reached-auto-split.md. A sibling
# escalation path off "in_umsetzung", not a further pipeline stage:
# bypasses the generic rank/skip-stage guard below entirely, the
# same way archiviert does above. Leaving "aufteilen_angefordert" or
# the worker-only "aufgeteilt" outcome again is unguarded
# bookkeeping, same treatment as leaving archiviert.
if current_phase in ("aufteilen_angefordert", "aufgeteilt"):
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
if is_intent:
stored["source"] = "intent"
state["cards"][card_id] = stored
save_state(state)
return None
if new_phase == "aufteilen_angefordert":
if not is_intent:
raise PermissionError("nur Intent-Karten können aufgeteilt werden")
if stored.get("operator_generated"):
raise PermissionError("Operator-Kinder dürfen nicht rekursiv aufgeteilt werden")
if not escalation_exhausted(stored):
raise PermissionError(
"Aufteilen ist erst möglich, wenn die Karte am "
"Iterationslimit hängen geblieben ist und ihre "
"automatischen Eskalationsversuche bereits verbraucht sind"
)
if repo_job_active():
raise PermissionError(
"a board-agent job is already queued or running against this repository"
)
runtime, extra = builder_column_payload()
pipeline_run_id = stored.get("pipeline_run_id") or uuid.uuid4().hex
job_id = write_job(
card_id,
intent_cards[card_id]["title"],
intent_cards[card_id]["intent_path"],
job_type="split",
runtime=runtime,
builder_report=stored.get("builder_report", ""),
pipeline_run_id=pipeline_run_id,
**extra,
)
stored["pipeline_run_id"] = pipeline_run_id
stored["last_job_id"] = job_id
stored["last_run_status"] = "queued_split"
stored["runtime"] = runtime
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
stored["source"] = "intent"
state["cards"][card_id] = stored
save_state(state)
return job_id
current_rank = PHASE_RANK[current_phase]
new_rank = PHASE_RANK[new_phase]
if new_rank > current_rank and new_rank - current_rank > 1:
raise PermissionError(
f"forward transition from '{current_phase}' to '{new_phase}' skips a pipeline stage"
)
job_id = None
if is_intent and new_phase in ("in_umsetzung", "in_umsetzung_leicht"):
if current_phase != "geplant":
raise PermissionError(
f"card is '{current_phase}', trigger only allowed from 'geplant'"
)
# "in_umsetzung"/"in_umsetzung_leicht" are the board's two
# dedicated Builder columns ("schwer"/"leicht") -- see
# docs/intent/board-builder-weighted-lanes.md.
lane = "leicht" if new_phase == "in_umsetzung_leicht" else "schwer"
# Guards the case the phase check alone misses: this same card
# (dragged back to geplant and forward again), a research/review
# job on the shared checkout, or an already-queued builder job
# in the same lane. agent-codex/builder.py's flock is the hard
# backstop if this check ever loses a race; this is the fast,
# clear rejection for the normal case.
blocked = builder_trigger_blocked(card_id, lane)
if blocked:
raise PermissionError(blocked)
runtime, extra = builder_column_payload(new_phase)
extra["lane"] = lane
pipeline_run_id = uuid.uuid4().hex
stored.update(
{
"source": "intent",
"slug": card_id,
"title": intent_cards[card_id]["title"],
"intent_path": f"docs/intent/{card_id}.md",
"lane": lane,
}
)
job_id = write_job(
card_id,
intent_cards[card_id]["title"],
intent_cards[card_id]["intent_path"],
runtime=runtime,
pipeline_run_id=pipeline_run_id,
**extra,
)
stored["pipeline_run_id"] = pipeline_run_id
stored["last_job_id"] = job_id
stored["last_run_status"] = "queued"
stored["runtime"] = runtime
stored["model"] = extra.get("model", "")
stored["reasoning_effort"] = extra.get("reasoning_effort", "")
stored["iteration_limit"] = extra["iteration_limit"]
stored["phase"] = new_phase
stored["updated_at"] = now_iso()
if is_intent:
stored["source"] = "intent"
state["cards"][card_id] = stored
save_state(state)
# The common case for a fan-out Builder child: a human drags it from
# "review" to "bereit_fuer_deployment"/"planung_mit_feedback"/
# "deployed" once the reviewer's verdict is in -- see
# docs/intent/board-subintent-fanout-synthesis.md. No-op for a card
# without a parent_id or one whose new phase isn't terminal yet.
fanout.maybe_synthesize(card_id)
orchestration.progress_all(write_job, load_column_config())
return job_id
class Handler(BaseHTTPRequestHandler):
server_version = "board-agent/1"
def _send_json(self, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json_body(self):
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0 or length > 8192:
raise ValueError("invalid request body")
raw = self.rfile.read(length)
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("body must be a JSON object")
return data
def log_message(self, format_string, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), format_string % args))
def do_GET(self):
path = urlsplit(self.path).path
if path in ("/", "/index.html", "/board.html"):
self._serve_board_html()
return
if path == "/api/board":
try:
result = {"cards": merged_board(), "runtime": board_runtime(), "column_config": load_column_config()}
except (OSError, ValueError) as error:
print(f"Board state unavailable: {error}", file=sys.stderr)
self._send_json(503, {"error": "Board state unavailable; no data was reset."})
return
self._send_json(200, result)
return
if path == "/api/board/column-config":
self._send_json(200, load_column_config())
return
if path == "/api/board/runtime":
self._send_json(200, {"runtime": board_runtime()})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/telemetry", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
payload = read_card_telemetry(match.group(1))
except (LookupError, OSError, ValueError):
self._send_json(404, {"error": "telemetry not found"})
return
self._send_json(200, payload)
return
match = re.fullmatch(r"/api/board/pipelines/([A-Za-z0-9-]{1,128})/telemetry", path)
if match:
try:
payload = telemetry.get_pipeline_run(
Path(os.environ["BOARD_TELEMETRY_DB"]), match.group(1)
)
except (OSError, ValueError):
self._send_json(404, {"error": "telemetry not found"})
return
self._send_json(200, payload)
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/review", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
report = read_review_report(match.group(1))
except (LookupError, OSError, UnicodeError):
self._send_json(404, {"error": "review report not found"})
return
self._send_json(200, report)
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/research", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
report = read_research_report(match.group(1))
except (LookupError, OSError, UnicodeError):
self._send_json(404, {"error": "research report not found"})
return
self._send_json(200, report)
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/research-intent", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
intent = read_research_intent(match.group(1))
except (LookupError, OSError, UnicodeError):
self._send_json(404, {"error": "research intent not found"})
return
self._send_json(200, intent)
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/intent", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
intent = read_intent(match.group(1))
except (LookupError, OSError, UnicodeError):
self._send_json(404, {"error": "intent not found"})
return
self._send_json(200, intent)
return
match = re.fullmatch(r"/api/board/agents/([a-z]+)", path)
if match:
try:
prompt = read_agent_prompt(match.group(1))
except (LookupError, OSError, UnicodeError):
self._send_json(404, {"error": "agent prompt not found"})
return
self._send_json(200, prompt)
return
self._send_json(404, {"error": "not found"})
def _serve_board_html(self):
html_path = Path(os.environ["BOARD_HTML_PATH"])
body = html_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/board/cards/([a-z0-9-]+)/retry-deployment", path)
if match:
try:
job_id = retry_deployment(match.group(1))
except (LookupError, PermissionError, ValueError) as error:
self._send_json(409, {"error": str(error)})
return
self._send_json(202, {"job_id": job_id})
return
match = re.fullmatch(r"/api/board/cards/([a-z0-9-]+)/plan", path)
if match:
try:
card_id = plan_card(match.group(1))
except (LookupError, PermissionError, ValueError) as error:
self._send_json(409, {"error": str(error)})
return
self._send_json(201, {"id": card_id})
return
match = re.fullmatch(r"/api/board/column-config/([^/]+)", path)
if match:
column = match.group(1)
if column not in COLUMN_CONFIG_DEFAULTS:
self._send_json(404, {"error": "not found"})
return
try:
config = set_column_config(column, self._read_json_body())
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, config)
return
if path == "/api/board/runtime":
try:
runtime = set_board_runtime(self._read_json_body().get("runtime"))
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"runtime": runtime})
return
if path == "/api/board/ideas":
try:
payload = self._read_json_body()
card_id = create_idea(str(payload.get("title", "")))
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(201, {"id": card_id})
return
if path == "/api/board/drafts":
try:
payload = self._read_json_body()
card_id = create_draft(str(payload.get("topic", "")))
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(201, {"id": card_id})
return
match = re.fullmatch(
r"/api/board/smoke-tests/(direct|split|remove-direct|remove-split)",
path,
)
if match:
try:
slug = create_smoke_test(match.group(1))
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(201, {"id": slug})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/fixer-answer", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
job_id = answer_fixer(match.group(1), self._read_json_body().get("answer"))
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "job_id": job_id})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/draft-answer", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
job_id = answer_draft(match.group(1), self._read_json_body().get("answer"))
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "job_id": job_id})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/draft-retry", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
job_id = retry_draft(match.group(1))
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "job_id": job_id})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/apply-routing", path)
if match and SLUG_RE.fullmatch(match.group(1)):
try:
phase = apply_routing_recommendation(match.group(1))
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "phase": phase})
return
match = re.fullmatch(r"/api/board/cards/([^/]+)/retry-review", path)
if match and SLUG_RE.fullmatch(match.group(1)):
# Body is optional: an explicit commit_hash is only needed for
# a card whose Review failed before commit_hash started being
# recorded at commit time (see retry_review()'s docstring) --
# every other caller just POSTs with no body at all, which
# _read_json_body() rejects (no/zero Content-Length) the same
# way it would for a genuinely malformed body; either way this
# route treats that as "no override", not an error.
try:
commit_hash = self._read_json_body().get("commit_hash")
except ValueError:
commit_hash = None
try:
job_id = retry_review(match.group(1), commit_hash)
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "job_id": job_id})
return
self._send_json(404, {"error": "not found"})
def do_PATCH(self):
path = urlsplit(self.path).path
parent_match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")/parent", path)
if parent_match:
child_id = parent_match.group(1)
try:
payload = self._read_json_body()
parent_id = str(payload.get("parent_id", ""))
link_parent(child_id, parent_id)
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"status": "ok"})
return
match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")", path)
if not match:
self._send_json(404, {"error": "not found"})
return
card_id = match.group(1)
try:
payload = self._read_json_body()
new_phase = str(payload.get("phase", ""))
job_id = update_phase(card_id, new_phase, payload.get("task_type"))
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
except ValueError as error:
self._send_json(400, {"error": str(error)})
return
self._send_json(200, {"status": "ok", "job_id": job_id})
def do_DELETE(self):
path = urlsplit(self.path).path
if path == "/api/board/smoke-tests":
try:
result = delete_smoke_test_cards()
except PermissionError as error:
self._send_json(409, {"error": str(error)})
return
self._send_json(200, {"status": "deleted", **result})
return
match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")", path)
if not match:
self._send_json(404, {"error": "not found"})
return
try:
delete_manual_card(match.group(1))
except LookupError as error:
self._send_json(404, {"error": str(error)})
return
self._send_json(200, {"status": "deleted"})
def main():
host = os.environ.get("BOARD_HTTP_HOST", "127.0.0.1")
port = int(os.environ.get("BOARD_HTTP_PORT", "8060"))
Path(os.environ["BOARD_STATE_DIR"]).mkdir(parents=True, exist_ok=True, mode=0o700)
try:
orchestration.progress_all(write_job, load_column_config())
except (OSError, ValueError, RuntimeError) as error:
print(f"Operator-Recovery konnte nicht fortgesetzt werden: {error}", file=sys.stderr)
server = ThreadingHTTPServer((host, port), Handler)
server.serve_forever()
if __name__ == "__main__":
main()