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/README.md

46 KiB
Raw Permalink Blame History

board-agent

Tailnet-only kanban board for the intent lifecycle (docs/intent/*.md) and independent research lifecycle (docs/research-intent/*.md). Research cards use recherche_angefordert, recherche_laeuft, recherche_fertig, recherche_fehlgeschlagen and archiviert; they never trigger Builder jobs. The browser projects the unchanged backend phases into the lifecycle groups Backlog, Ready, Running, Needs attention, Deploying, Done, and Archived. Cards remain stable intent objects; agent work is started through context-sensitive card buttons, not by dragging a card into an agent-named column. The API and persisted phase names remain compatible.

Started as a single-transition pilot (docs/intent/kanban-board-admin-trigger.md, superseded) that only triggered agents/admin.md. Phase 1 of docs/intent/git-intent-board-feedback.md (2026-09-06) replaced that with two automatic transitions:

  • Geplant → In Umsetzung (the Start Builder card action): hands the job off to the existing Builder role instead of running the Administrator directly — see "Builder hand-off" below.
  • Erfolgreicher Builder-Commit → Review (automatic): a completion watcher notices the Builder finished with a commit and queues a headless Reviewer run against that exact commit. Ordinary Board Builder jobs always set defer_deploy_until_review; only the positive verdict for that exact commit releases the persistent deployment request.
  • In Umsetzung → Aufteilen angefordert (the Split card action, only once a card is stuck at last_run_status == "limit_reached" with its one automatic retry already spent): queues a headless, read-only Planner run that splits the card's intent into up to 10 independent child intents — see "Split escalation" below (docs/intent/board-builder-limit-reached-auto-split.md).

Verified result states cannot be assigned by manual PATCH requests. Starting Review queues real work; positive review and successful research require worker results. Archiving and reactivation remain explicit user actions.

Workflow and recovery contract (September 2026)

  • A Builder commit is not a successful deployment. Exactly one structured REVIEW-RESULT object must name that commit and say approve or reject. Prose such as “not ready for deployment” cannot release deployment. Invalid output leaves the previous review report intact.
  • An approval queues activation automatically. The runner uses an immutable git+file://...?rev=<resolved-commit>#blunix source, never a mutable checkout or whatever later reached origin/dev. Its receipt records the full revision and /run/current-system closure after switch and service-health checks. Automatic rollback/divergence from its last verified revision is refused.
  • Only a matching approved revision plus activation receipt moves a card to Deployed. Existing cards without receipts show Deployment unverified; this change does not retroactively activate or certify old cards.
  • Failed activation offers Retry deployment, reusing the approved commit without a model or another Builder run. Failed publication retains the Git worktree for recovery instead of deleting the unpublished change. An empty Builder result is not counted as execution success or proof of goal completion.
  • State writers share a cross-process transaction lock. Queue publication uses a durable outbox and queue-local claim markers, so recovery does not recreate a job already claimed between publication and receipt. Completion records are applied transactionally before moving to consumed, with content deduplication and pipeline/attempt checks. Malformed records go to results-rejected.
  • Archived Operator families no longer restart children in the scheduler. Downstream Builder and deployment queues count as active work for card guards.

The board defaults to active goals, with search, an attention-only view, an archive view, collapsible child cards and a separate settings dialog. Cards separate goal state, job outcome and next responsibility. Details & evidence contains Overview, Attempts, Evidence, Conversation and Changes. Missing history is displayed as missing, not reconstructed. Cost totals show measurement coverage. An API failure keeps the last visible data and explicitly marks it stale.

Recovery boundaries

Restic includes the atomic board state and persistent deployment ledger. These are metadata backups, not a coordinated snapshot of every job queue or the live SQLite telemetry database. Stop the board workers, both Builders and deployment runner before restoring metadata; reconcile pending jobs and current activation before re-enabling automation. Never restore a pending outbox into running queues or delete claim markers while requests can still be replayed.

Repository state, not the card label, remains authoritative for code; activation receipts prove a past switch, not ongoing health or fulfillment of every acceptance criterion. A model review is not an independent exhaustive integration test.

Regression checks

Run nix develop --command python -m pytest -q with this directory and ../agent-codex/test_agent_codex.py as the test targets. The standalone test_board_browser.cjs uses Python for fixture presentation and Playwright for browser assertions. Set PLAYWRIGHT_MODULE to the installed module path, CHROMIUM_BIN to Chromium and optionally PYTHON_BIN to Python, then run it with Node. Every HTTP response is mocked; any non-GET request fails the test. Screenshots are written under /tmp/intent-board-candidate-*.png.

Klärung & Reparatur

Die kontextsensitiven Aktionen speichern beim Aufruf von klaerung_reparatur einen unveränderlichen Auftragstyp: implementation_status, route_next oder repair, zusammen mit Runtime, Modell und Reasoning. Es gibt weder ein globales Mode-Feld noch einen Prompt-Dialog. Diagnose wird vor Repair angeboten. Die beiden ersten Typen starten eine frische, feste Inspector-Rolle mit read-only Sandbox. Sie liefern getrennte, validierte INSPECTION_RESULT- bzw. ROUTING_RESULT-Blöcke. Deployment-Evidenz stammt ausschließlich aus dem vertrauenswürdigen Wrapper-State; das Modell kann weder deployen noch die Karte bewegen. Eine Routing-Empfehlung bleibt bis zum expliziten UI-Button als routing_pending gespeichert.

repair ist nur bei echten Fehlern oder negativem Review zulässig und nutzt den isolierten Builder-Worktree als pipeline_role="fixer". Sein eigener FIX_RESULT kennt ausschließlich repaired, needs_input, not_applicable und failed; eine universelle TARGET_PHASE existiert nicht. repaired geht zwingend in einen separaten Review; not_applicable bleibt ohne erfundene Zielphase sichtbar in der gemeinsamen Spalte. Rückfragen und Antworten werden persistiert, jede Antwort startet eine frische Session. Maximal zwei Repair-Versuche sind erlaubt; der einmalige reine Formatkorrekturlauf im Builder-Harness zählt nicht als Kartenversuch und ein erneut ungültiges Format fällt deterministisch in eine Rückfrage zurück. Ein limit_reached ist dabei erst nach Verbrauch der vollständigen Eskalationsleiter ein Repair-Fall. Ein laufender oder später erfolgreicher Eskalationsversuch zeigt keinen Fixer-Button.

Normale Board-Builder- und Fixer-Commits setzen defer_deploy_until_review: der Builder-Harness pusht den isolierten Commit, erzeugt aber keinen Deployment-Auftrag. Erst ein positiver Review schreibt einen auf den geprüften Commit gepinnten Auftrag in die bestehende persistente agent-codex-Deploy-Queue. Deren Runner validiert weiterhin den festen Host blunix und das feste Repository; das Modell besitzt weder Zugriff auf diese Queue noch auf sudo/nixos-rebuild. tnix und pinix sind kein mögliches Fixer-Deploymentziel.

Runtime- und Modellwahl

Die drei job-auslösenden Spalten (in_umsetzung, review und recherche_laeuft) bieten eigenständige Runtimes für Codex, Claude und OpenRouter. OpenRouter ist keine Codex-Modelloption: Der Worker setzt den Codex-Exec-/JSONL-Harness mit model_provider="openrouter" auf den OpenAI-kompatiblen Responses-Endpunkt https://openrouter.ai/api/v1 um. Für OpenRouter stehen ausschließlich diese Modelle im spaltenabhängigen Dropdown:

  • moonshotai/kimi-k2-thinking
  • deepseek/deepseek-v3.2
  • cohere/north-mini-code:free
  • nvidia/nemotron-3-super-120b-a12b:free

Eine leere Modellwahl verwendet die rollenbezogene *_OPENROUTER_MODEL-Umgebungsvariable (Builder, Reviewer, Researcher oder Planner). Der über agenix bereitgestellte Schlüssel bleibt als Dateipfad in *_OPENROUTER_API_KEY_FILE; erst unmittelbar vor einem OpenRouter-Aufruf liest der jeweilige Wrapper die Datei und setzt OPENROUTER_API_KEY nur im Environment des Kindprozesses. Kommandozeile, Queue-Payload und Board-State enthalten den Schlüssel nie. Reviewer, Researcher und Planner/Split behalten auch mit OpenRouter ihre read-only Codex-Sandbox; Builder behält workspace-write, Streaming-Auswertung, Antwortkürzung und Iterationslimit des vorhandenen Codex-Harnesses.

The final Archiviert column is a permanent bookkeeping state for inactive cards. Moving any card to it never queues a Builder, Reviewer, or deployment job. Archived cards remain readable, including their Intent and review report. Moving an archived card back to Geplant is also bookkeeping; Archiviert → In Umsetzung is rejected so reactivation must use the deliberate Archiviert → Geplant → In Umsetzung sequence.

Not part of this phase (see the intent document's own scope note and docs/intent/builder-iterationslimit.md): board state staying in BOARD_STATE_DIR/state.json rather than git-versioned docs/board/<slug>.yaml files with automatic local commits, and real OIDC/Dex self-check auth for the Web-UI (still Traefik BasicAuth, see "Auth" below) — both explicitly deferred, not oversights.

Architecture

docs/intent/ und docs/research-intent/ sind Corpus-Verzeichnisse und damit getrackte Eingaben des Repositorys. Das Repository enthält daher auch bei leerem Research-Corpus den Platzhalter docs/research-intent/.gitkeep. ExecStartPre erzeugt ausschließlich privaten Runtime-State unter /var/lib/board-agent/ und schreibt niemals in den schreibgeschützten Checkout.

Browser (board.html, served by trigger.py)
  -> GET /api/board            merged view: docs/intent/*.md (default
                                phase "geplant") + manual "idea" cards,
                                both overlaid with BOARD_STATE_FILE
  -> GET /api/board/column-config   return the default-filled configuration
     for the job-triggering columns
  -> POST /api/board/column-config/<column>   update a validated partial
     configuration for `in_umsetzung`, `review`, or `recherche_laeuft`
  -> POST /api/board/ideas     create a manual backlog card
  -> POST /api/board/drafts    start an interactive Planner intake
                                dialogue ("draft" card, no backing file
                                yet) -- see "Interactive Planner intake"
                                below
  -> POST /api/board/cards/<id>/draft-answer   answer the Planner's
     latest draft question, queue the next turn
  -> POST /api/board/cards/<id>/draft-retry    re-run a failed draft
     turn unchanged
  -> DELETE /api/board/cards/<id>   remove a manual backlog card, or an
     abandoned draft that never finished
  -> PATCH /api/board/cards/<id>    move a card to a new phase
       -> for an intent card moving geplant -> in_umsetzung:
            - reject (409) unless the card's current phase is exactly
              "geplant" -- the double-trigger guard
            - write a job_type="builder" job into board-agent-worker's
              inbox/, including an immutable runtime snapshot
            - record phase=in_umsetzung + last_run_status=queued
       -> every other move: just persists the new phase, no side effect

systemd.paths.board-agent-worker (watches inbox/*.json)
  -> board-agent-worker.service (oneshot, worker.py), dispatches by
     job_type:
       "builder" (default, for legacy/missing job_type too):
         - reads the *current* docs/intent/<slug>.md content itself (not
           whatever the job file says)
         - translates it into ../agent-codex's own builder-inbox/ schema
           (sender=requested_by=Builder owner JID, so it runs
           immediately with no /annehmen gate) and writes it there --
           no `codex exec` happens in this process for this job type,
           the already-built ../agent-codex/builder.py pipeline (atomic
           session, iteration limit, stash+commit) does the actual work
         - job carries on_complete_dir pointing at
           BOARD_BUILDER_RESULTS_DIR
       "review":
         - uses the originating Builder job's runtime snapshot: Codex runs
           read-only with gpt-5.6-luna/none; Claude uses its existing
           settings and disabled write tools; OpenRouter uses the same
           read-only Codex harness with its own provider and role default
         - reviewer returns its report as a
           ===REVIEW-REPORT===...===END-REVIEW-REPORT=== marker block;
           this script extracts it and writes docs/review/<slug>.md
           itself, outside the model process (see "Why read-only, not a
           scoped writable dir" below)
         - sets the card's phase from the report's decision line
           ("bereit für Deployment" -> bereit_fuer_deployment,
           "zurück zur Planung" -> planung_mit_feedback)
         - keeps the decision, report path, and reviewed commit on the card,
           then checks whether this terminal result completes a fan-out and
           can generate its synthesis
       "research":
         - verwendet den unveränderten Runtime-Snapshot des Board-Jobs:
           Codex und OpenRouter laufen read-only über den Codex-Harness,
           Claude mit den shell-fähigen Researcher-Einstellungen
         - liest docs/research-intent/<slug>.md und übergibt den Auftrag
           an agents/researcher.md
         - extrahiert den
           ===RESEARCH-REPORT===...===END-RESEARCH-REPORT===-Block und
           schreibt ihn selbst nach docs/research/<slug>.md
         - setzt die Karte auf recherche_fertig bzw. bei Fehlern auf
           recherche_fehlgeschlagen
       "split" (see "Split escalation" below):
         - only reachable via the "aufteilen_angefordert" column, itself
           only reachable once a card's automatic limit_reached retry is
           already spent -- see trigger.py's update_phase()
         - run_split() invokes the Planner role read-only, same pattern as
           review/research: Codex `--sandbox read-only`, Claude with all
           write tools disabled
         - context is the unchanged original intent plus the stored
           builder_report from the failed attempt(s), no git-stash/diff
           access
         - parses a
           ===SPLIT-REPORT===...===END-SPLIT-REPORT===-Block containing up
           to 10 ===CHILD===...===END-CHILD=== sub-blocks and writes each
           as its own docs/intent/<child-slug>.md itself, outside the
           model process -- all-or-nothing: any malformed sub-block, a
           slug collision, or more than 10 children discards the whole
           proposal, writing nothing
         - each new child gets an initial state entry (phase "geplant",
           parent_id set) -- no auto-trigger, same manual
           "Geplant -> In Umsetzung" path as any other card
         - the parent card moves to the terminal phase "aufgeteilt" with
           its child_slugs recorded
       "draft" (see "Interactive Planner intake" below):
         - only reachable via POST /api/board/drafts (opening turn),
           /draft-answer (a follow-up) or /draft-retry (a failed turn,
           unchanged) -- never a PATCH phase move
         - run_draft() invokes the Planner role read-only with **no**
           repository access at all, unlike every other role above -- a
           draft has no existing files to read yet
         - each turn resends the full conversation so far (state.json's
           draft_conversation, not a persistent model session) and gets
           back exactly one of a ===DRAFT-QUESTION===...
           ===END-DRAFT-QUESTION=== follow-up (up to four quick-reply
           options) or a ===DRAFT-INTENT===...===END-DRAFT-INTENT===
           block once the Planner has enough context
         - a question just updates draft_conversation and the card's
           phase (entwurf_frage); a finished intent is written to
           docs/intent/<slug>.md by this process (never the model, same
           marker pattern as split above), under the same repo lock
           run_split() uses -- but only for this one write, not for an
           ordinary question turn
         - record_draft_intent() then replaces the draft-<uuid> state
           entry with a normal "geplant" intent card (draft_origin set
           for audit) -- no auto-trigger, same manual path as any split
           child
     Alle Pfade: job file -> done/ (success) oder failed/ (error attached);
     full report goes to stdout, i.e. `journalctl -u board-agent-worker`.

  If systemd fails before `worker.py` can process the queue (for example in
  `ExecStartPre` or while setting up the sandbox), `ExecStopPost` invokes the
  fixed `failure_handler.py`. It accepts exactly one queue entry from the
  configured inbox/processing directories, revalidates job ID, slug, source
  and fixed intent path, atomically moves that entry to failed/, and records
  `last_run_status="failed"`, `failure_stage` (`pre_start`,
  `sandbox_setup` or `worker`) and a bounded, cleaned `failure_detail`. Zero
  or multiple candidates are left untouched and only produce a journal hint.
  A hook for an old job cannot overwrite a later successful run because the
  state job ID is checked before updating.

systemd.paths.board-agent-completion-watcher (watches builder-results/*.json)
  -> board-agent-completion-watcher.service (oneshot, completion_watcher.py)
       - ../agent-codex/builder.py writes a completion record here for
         any job whose payload carried on_complete_dir -- the builder
         hand-off above is the only thing that ever sets it
       - status "committed": queues a job_type="review" job (commit hash
         and runtime snapshot from the record) in this board's own inbox/,
         card -> "review"
       - a deploy-runner record contains both status "committed" and a
         deployment_status; it is accepted as deployment evidence only when
         the card already carries Reviewer approval for that exact commit.
         Without that durable release, committed -> review wins, the reported
         deployment remains visible as premature evidence, and synthesis is
         not triggered
       - anything else (no_changes/check_failed/limit_reached/failed):
         card phase is left alone (no automatic fallback for a Builder
         failure, only for a negative Review) -- last_run_status plus the
         Builder's own final response text (builder_report) are recorded,
         so the card shows a "Builder-Bericht" element instead of the
         reasoning only existing in agent.db
       - claimed records move into builder-results-consumed/, not deleted

Fehlerklassen sind absichtlich getrennt: Modellfehler und Fehler innerhalb
von `process_job()` werden als `worker` gespeichert; Wrapper-/Vorstartfehler
als `pre_start`; ein Fehler beim Aufbau der systemd-Sandbox kann als
`sandbox_setup` klassifiziert werden. Der Browser erhält nur die bereinigte
Kurzbeschreibung, niemals Journal-, Prompt-, Environment- oder Secret-Inhalte.

Builder hand-off (why no admin.md call happens anymore)

The original pilot ran agents/admin.md directly in this process. docs/intent/git-intent-board-feedback.md explicitly wants the Builder role instead for this transition — and reusing the Builder means reusing its already-built, tested, deployed execution path (../agent-codex/builder.py) rather than duplicating atomic sessions/iteration-limit/stash-commit logic a second time here. So enqueue_builder_job() in worker.py is a thin translator: it writes a job into ../agent-codex's builder-inbox/ queue and gets out of the way. Everything from there on — sandboxing, nix flake check/nix build before commit, push to dev — is exactly what ../agent-codex/builder.py already does for XMPP-triggered jobs; see ../agent-codex/README.md.

Isolated worktrees, still not implemented (deliberate). The intent document docs/intent/board-builder-worktree-parallelism.md calls for board-triggered Builder jobs to run in an isolated git worktree/job-branch so multiple independent cards can build in true parallel. That worktree infrastructure — plus the concurrent-dispatch rearchitecture of agent-codex/worker.py's currently sequential job loop, worktree cleanup on crash/SIGTERM, and the ordering question for merging several finished job branches into dev — is deliberately not built yet: it touches the production commit/push/auto-deploy path with no way to run its test suite or a real build/flake-check from this environment, and the VPS overload risk the intent itself calls out has already happened live once. trigger.py's builder_trigger_blocked() now allows multiple different cards to be queued as builder jobs at once (capped by BOARD_MAX_CONCURRENT_BUILDER_JOBS, default 1) instead of rejecting a second card outright, but agent-codex/builder.py still runs every job against the one shared checkout, serialized by its existing flock — so queued jobs execute one at a time regardless of the cap. Research/review jobs are unaffected: repo_job_active() still blocks a new builder job if any research/review job is queued or running, since those still touch the same shared checkout with no lock of their own on the trigger side. Revisit the worktree/parallel-dispatch piece once real concurrent board-triggered load actually shows up.

Split escalation (docs/intent/board-builder-limit-reached-auto-split.md)

A card that hits the Builder iteration limit gets its automatic retries per docs/intent/board-operator-escalation-ladder.md below (one flat retry by default, or an Operator-authored ladder of up to two escalation steps for a card that went through the Operator). Once that ladder is fully exhausted and the last attempt still ends in limit_reached, the card is simply too big for one atomic Builder session — the Split card action is the deliberate, manual escalation: trigger.py's update_phase() only accepts that move when last_run_status == "limit_reached" and auto_retry_attempts >= len(escalation_ladder_for(card)) (orchestration.escalation_ladder_for(), see below), and (like a builder trigger) only when no other board-agent job is queued or running against the shared checkout.

The queued job_type="split" job runs entirely inside board-agent-worker.service (no hand-off to agent-codex, unlike "builder"): run_split() in worker.py invokes the Planner role read-only with the original intent plus the stored builder_report as context, asks for up to 10 independent child intents in a ===SPLIT-REPORT=== marker block, and writes each ===CHILD=== sub-block as its own docs/intent/<child-slug>.md — the wrapper applies, the model never writes. Any malformed sub-block, a slug collision (with the parent or another child), or more than 10 children discards the entire proposal; zero files are written, matching run_review()'s existing "reject rather than partially apply" precedent for a missing decision line.

Each new child card starts in "Geplant" with parent_id set — no auto-trigger, the same manual "Geplant → In Umsetzung" path as any other card. The parent card itself moves to the terminal phase "Aufgeteilt" with child_slugs recorded; like "Archiviert", it is reachable only this one way (never a direct PATCH target) and any move away from it again is plain bookkeeping. A failed split attempt (bad proposal, Planner error, timeout) leaves the parent card exactly where it was, in "Aufteilen angefordert", for a manual retry — no forced fallback phase.

Deliberately not built here (left to docs/intent/board-subintent-fanout-synthesis.md, a separate, not-yet-built intent): automatic synthesis once every child reaches an end state, and any Board-UI grouping/tree display beyond the plain parent_id/child_slugs text already on each card.

Interactive Planner intake (docs/intent/board-interactive-planner-intake.md)

A third way to put a card on the board, next to hand-writing a docs/intent/<slug>.md file and the plain "+ Karte" backlog prompt: the "+ Karte (interaktiv)" backlog button starts a multi-turn question-and-answer dialogue with the Planner role instead. create_draft(topic) writes a "draft" card (draft-<uuid>, no backing file) and immediately queues its opening turn.

Each job_type="draft" turn (run_draft() in worker.py) invokes the Planner read-only and with no repository access at all — no -C into the checkout, no --add-dir — since a still-forming draft has no existing files to read and giving it repo access would have no upside. The full conversation so far (draft_conversation on the card, not a persistent model session) is resent every turn. The reply is exactly one of two marker blocks: ===DRAFT-QUESTION===...===END-DRAFT-QUESTION=== (a follow-up question plus up to four optionen: a | b | c | d-style quick-reply suggestions the frontend renders as buttons, alongside a free-text answer field) or ===DRAFT-INTENT===...===END-DRAFT-INTENT=== (a slug: ... / --- / <intent body> block, same header shape as run_split()'s ===CHILD=== sub-blocks) once the Planner has enough context. Both present, or neither, is rejected the same way extract_split_children() already rejects a malformed split proposal.

A question just updates draft_conversation and the card's phase (entwurf_frage) — no repo lock, runs alongside Builder/Review/Research/ Split without contending for anything. Finishing the dialogue is the one moment this job type touches the shared checkout: the wrapper (never the model) writes docs/intent/<slug>.md under the same acquire_repo_lock() run_split() uses, held only for that narrow write. A slug collision discards the proposal entirely and leaves the card in entwurf_fehlgeschlagen for a manual POST .../draft-retry (resends the last turn unchanged — no new operator input needed). On success, record_draft_intent() replaces the draft-<uuid> state entry with a normal "geplant" intent card (draft_origin kept for audit) — no auto-trigger, the same manual "Geplant → In Umsetzung" path as any other card, including a split child.

answer_draft(card_id, answer) only accepts an answer while the card is at entwurf_frage (max 4000 characters, max 40 conversation turns total — a notional ceiling, not an expected normal length). delete_manual_card() (renamed from delete_idea()) now accepts both "idea" and "draft" sources, so an abandoned dialogue disappears through the same "×" button as a backlog idea.

Deliberately not built here, per the intent document's own scope note: file attachments (image/audio — no runtime takes non-text input through this board's CLI wrappers today), a structured fixed-field form as a model-free alternative, a voice/phone-call interface, and any per-column runtime/model override UI for drafts (fixed BOARD_PLANNER_* defaults, same simplification run_split() already makes).

Escalation ladder (docs/intent/board-operator-escalation-ladder.md)

Added 10.09.2026, generalizing the single flat auto_retry_attempts retry above into a short, Operator-authored ladder. The Operator's ===OPERATOR-DECISION=== schema gained an optional escalation field (at most two entries, each {model, reasoning_effort, iteration_limit}): on the parent card for direct_builder, per child for split (the top-level escalation must then be empty — it lives on each child instead), always empty for needs_human. A response that omits the field entirely still validates — extract_operator_decision() defaults it to [] before the exact-keys check, so older Operator runs and any hand-built response without this field keep working unchanged.

Each step's model is validated against orchestration.MODEL_POOL, a new per-runtime allowlist kept in sync by hand with board.html's existing MODEL_OPTIONS config dropdown (same pragmatic duplication already used elsewhere in this repo, e.g. the BOARD_*_MODEL defaults in board-agent.nix). Runtime and lane never change mid-ladder — only model, reasoning_effort and iteration_limit (5–40) may vary per step, since the two Builder lanes already share identical model capabilities and only differ by queue/instance (board-builder-weighted-lanes.md).

orchestration.apply_direct()/_apply_split_unlocked() persist the validated ladder onto the card as escalation_attempts. fanout.escalation_ladder_for(card) is the single shared accessor (lives in fanout.py, which orchestration.py already imports, to avoid a circular import; orchestration.escalation_ladder_for is a re-export of the same function): it returns escalation_attempts if the card has one, otherwise a synthetic one-step ladder that repeats the card's own current model/reasoning_effort/iteration_limit — exactly the original flat retry, unchanged, for any card that never went through the Operator (e.g. one dragged straight from "Geplant" to "In Umsetzung").

completion_watcher.py's process_record() now checks auto_retry_attempts against this ladder's length instead of a hardcoded < 1: on limit_reached with a step still open, it queues the next Builder attempt using that step's model/reasoning_effort/iteration_limit (not the just-failed attempt's identical parameters) via enqueue_builder_retry_job()'s new optional overrides. orchestration. child_failed() and fanout.child_terminal() — the Operator-family scheduler's and the older manual fan-out's respective "has this child given up" checks — both use the same dynamic ladder length rather than the old fixed >= 1, so an Operator child only counts as failed (blocking its parent, or Aufteilen angefordert's own eligibility check above) once its own full ladder is spent, not after the first limit_reached.

board.html shows the attempt number and escalation plan on any card with auto_retry_attempts > 0, explicitly distinguishing an in-flight escalation, a recovered escalation, and terminal exhaustion.

Fan-out / synthesis (docs/intent/board-subintent-fanout-synthesis.md)

Any two known cards (intent, research, or manual idea) can be linked as parent/child via PATCH /api/board/cards/<child-id>/parent with body {"parent_id": "<parent-id>"} — a small, board-UI-free API endpoint, exactly the "lightweight mechanism... ohne dass dafür Board-UI-Arbeit nötig ist" the intent calls for. trigger.py's link_parent() validates both slugs are real cards, then delegates the actual bookkeeping write to fanout.link_child(): parent_id on the child, the child's slug appended to depends_on on the parent (both state.json overlay fields, nothing in the intent documents themselves). Deliberately a separate mechanism from the split feature's child_slugs/parent_id above — same parent_id field on the child (reused as-is), but depends_on on the parent instead of child_slugs, since a fan-out parent's children are manually linked one at a time rather than written atomically by one split run; a card can in principle carry both if it is simultaneously an auto-split parent and a fan-out parent.

fanout.py (shared by trigger.py, worker.py and completion_watcher.py, same same-directory import pattern as completion_watcher.py's existing import worker) defines child_terminal(card_id, stored): a Research child is terminal at recherche_fertig/recherche_fehlgeschlagen/archiviert; a Builder (intent) child is terminal at bereit_fuer_deployment/planung_mit_feedback/deployed/archiviert, or while still sitting in in_umsetzung with a stalled last_run_status (no_changes/failed/check_failed, or limit_reached with its one automatic retry already spent) — a run that never produced a commit still counts as "reached an end state", per the intent's "auch über Teilfehlschläge berichten, statt auf eine manuelle Wiederholung zu warten". review phase itself is not terminal. It represents the interval between a committed Builder result and completion of its queued Review job. A successful Review routes the card directly to its validated terminal outcome, bereit_fuer_deployment or planung_mit_feedback; no human drag is required.

maybe_synthesize(slug) is called after every state change that could plausibly make slug newly terminal: in worker.py after a successful Review has saved its decision phase and after a Research job's success or failure update_state() call, in completion_watcher.py after its fallback branch for a Builder run that stalled without a commit, and in trigger.py's update_phase() after essentially every phase-saving branch (including archiving and manual terminal moves). The automatic Review call is the common case for a reviewed Builder child and runs only after the decision and review metadata are durable in state. maybe_synthesize() looks up slug's parent_id, no-ops if absent, and otherwise checks every sibling in the parent's depends_on list via child_terminal(); once all are terminal (success or failure — see the intent's "Annahmen"), it writes a new docs/intent/<parent-id>-synthese.md summarizing each child's phase/status/result-link (commit_hash for a Builder child, research_report for a Research child — reusing the existing per-card fields directly rather than introducing a separate result_ref), marks the parent synthesis_generated: true so this only ever fires once, and stops there: the synthesis card starts in geplant like any other newly-discovered docs/intent/*.md file, no automatic trigger, no automatic quality judgment — a person reviews and edits the draft before its own manual "Geplant → In Umsetzung".

Deliberately not built here: an Evaluator role or automated judgment of whether the parent's goal was actually met; conflict handling between overlapping children (fan-out is for independent children only, same assumption as the worktree-parallelism intent); and true parallel Builder children — agent-codex/builder.py still serializes every Builder run against the one shared checkout regardless of how many cards are queued (see "Builder hand-off" above), so multiple Builder children of one parent simply queue up rather than actually run in parallel today.

Board-UI nesting (docs/intent/board-nested-child-intents.md, added 2026-09-09): a card whose parent_id still resolves to an existing card no longer appears as its own column card at all — board.html's render() skips it via isNestedChild() — and is instead rendered embedded inside that parent's card by renderCard()'s own recursion, indented under a .children container. This applies uniformly to both a manually linked fan-out child and an Operator split child, since both use the same parent_id field. Each embedded child is still the exact same, fully interactive card element (badges, dialogs, and contextual actions). A blocked parent identifies its blocking child and offers a Zum Kind navigation action; Diagnose/Repair target that child rather than the whole family. If the parent card is gone (deleted state, or never existed), the child falls back to its own column instead of disappearing from the board. renderCard() tracks an ancestry set through the recursion so a manually mislinked parent_id cycle (only parent_id === child_id is rejected server-side) can't turn this into an infinite loop client-side.

Operator outcome visibility (found live 2026-09-10)

orchestration.apply_needs_human() and the exception branch in worker.py's job_type == "operator" handling both used to set the card's phase to durch_kinder_blockiert ("blocked by children") even though neither case ever produces a single child — a needs_human decision and an Operator job crash both happen before any split, so there is nothing to be blocked by. They now use a separate phase, operator_klaerung_noetig ("Operator: Klärung nötig"), leaving durch_kinder_blockiert exclusively for orchestration.py's three genuine failed-/blocked-child cases (progress_all()), where blocking_children names real cards.

Separately, appendOrchestrationBadges() in board.html returns immediately for a card with no parent_id/depends_on/child_slugs — the majority of the badge row (status, blocking children, recommended profile, ...) never rendered for a needs_human card, since it never gets a family at all. operator_reason and human_action were stored on the card but never read by board.html anywhere. appendOperatorReason() is a small, separate function called unconditionally from renderCard() (not nested inside the family-only badge function) that renders the Operator's decision, its reason, and — for needs_human — the recorded human_action as the concrete next step, regardless of whether the card has any family at all. It renders for every Operator decision (direct_builder/split included), not only needs_human, since the reasoning is useful context in all three cases and this is meant as a general "why did this land here" mechanism rather than a one-off fix for this single phase.

Run-metrics pilot (docs/intent/board-intent-run-metrics-baseline.md)

Opt-in, per-slug run history — never automatic, never board-wide. A run is only ever recorded for a slug listed in the BOARD_METRICS_SLUGS environment variable (comma-separated, empty by default in board-agent.nix; edited directly there to start or end a pilot, same convention as the model/reasoning-effort env tunables above it). For every other slug, metrics.record_run() is a complete no-op — no new field is ever written to that card's state.json entry.

metrics.py (imported by trigger.py, worker.py and completion_watcher.py, same sibling-module pattern as fanout.py) appends one entry to card["run_history"] per finished job — one entry per Builder completion record, one per Review job, one per Research job — each carrying run_kind ("board" or "baseline_single_prompt"), status, job_type, whatever of runtime/model/reasoning_effort is known, duration_seconds (best-effort: derived from the originating job's own requested_at for Review/Research, from the Builder job ID's embedded queue timestamp for Builder), and an outcome-specific field (commit_hash, review_decision, or research_report). History is capped at metrics.MAX_RUN_HISTORY (200) entries per card. merged_board() exposes a compact metrics_summary (total/board/baseline/ successful) per card, rendered on the board as a small badge by appendMetricsBadge() — null (nothing rendered) for the vast majority of cards that were never enrolled.

metrics_baseline.py is the other half: a manually run CLI, deliberately not a systemd service, that sends an existing card's intent markdown straight to a model via worker.run_baseline() — no -C into agents/ (no role prompt), no --add-dir/repo access, and (for Claude) the same --disallowed-tools Edit,Write,MultiEdit,NotebookEdit the Reviewer uses — the null-hypothesis comparison point the intent asks for: what a single direct prompt to a strong model produces without the Board's Builder/Reviewer pipeline at all. Only codex and claude runtimes are supported (OpenRouter's role-scoped config helper doesn't have a "baseline" role and duplicating it wasn't worth the risk for a pilot tool). Both --model and --runtime are required arguments — deliberately no silent default, since the whole point is comparing against a model the operator explicitly chose. The raw response is written to docs/metrics/<slug>-baseline-<unix-timestamp>.md for manual grading (no automatic quality judgment, same as every other role in this file); run to run:

sudo systemctl show board-agent-worker.service --property=Environment
# then, as the drone user, with that same environment:
python3 "$BOARD_METRICS_BASELINE_SCRIPT" <slug> --runtime claude --model opus

Deliberately not built here: automatic telemetry for every board run (the allowlist gate is the point, not an oversight); any LLM-as-judge or other automated quality scoring of a baseline response (grading stays manual); a dashboard beyond the one compact per-card badge; and any decision about long-term retention or privacy for a wider rollout beyond the pilot's small, deliberately chosen slug set.

Why read-only, not a scoped writable dir (Reviewer sandbox)

The intent document assumed Codex's --sandbox workspace-write --add-dir docs/review could scope the Reviewer to writing only there. Verified false against this environment's actual codex-cli build (2026-09-06): a real test run with --add-dir <repo>/docs/review still succeeded at writing a file outside that directory — --add-dir does not scope writes path-precisely here, it's closer to "writable in general" once workspace-write is on. Reads, by contrast, work everywhere regardless of sandbox mode or --add-dir (also verified live). So run_review() runs fully --sandbox read-only (a real, verified restriction — a write attempt under it fails at the shell level, "read-only file system") and gets its report back as a marker block in the text response instead, which this script writes to docs/review/<slug>.md itself, outside Codex's sandbox entirely. Same principle as ../agent-codex/builder.py's ===COMMIT-MESSAGE=== marker: Codex proposes, the wrapper applies.

Reviewer visual verification (docs/intent/board-reviewer-visual-verification.md)

Added 10.09.2026, prompted directly by a live review report (web-ui-system-darkmode, commit 4b2047e) that flagged "no actual rendering checked, only the CSS variable structure" and "contrast only visually plausibilized from the chosen hex values" as unresolved uncertainties — a pure text diff review genuinely cannot close either gap. The Claude branch of run_review() now gets an extra, narrowly scoped tool for exactly that: board-agent/screenshot.py takes only a commit hash and a repo-relative .html path, extracts that file's exact content at that commit via git show <hash>:<path> (never the live working tree, which may have moved on by the time Review actually runs), and renders it headless in both light and dark prefers-color-scheme, printing both PNG paths for the Reviewer to inspect with its own Read tool.

--allowedTools gets a second, dynamically-built entry pinning the exact resolved sys.executable <BOARD_SCREENSHOT_SCRIPT> <commit_hash> invocation — same fixed-command precedent as the existing Bash(git *) grant, just for one script instead of a whole command family; the script re-validates both arguments itself regardless (commit-hash shape, .html suffix, no absolute path, no ..). REVIEW_PROMPT_TEMPLATE gets a {visual_verification} section filled in only for Claude, with the commit hash already baked into the example command — Codex/OpenRouter get an empty string here, since --sandbox read-only blocks every write (see "Why read-only, not a scoped writable dir" above), leaving Chromium nowhere to put a screenshot.

Chromium flag gotcha, verified live 2026-09-10, not assumed: --force-prefers-color-scheme has no effect at all on a file:// page in this headless Chromium build (byte-identical screenshots for both values); --blink-settings=preferredColorScheme=<0|1> is what actually works (0 renders dark, 1 renders light — confirmed by the rendered pixel color, not the flag's nominal enum ordering).

--no-sandbox in the script's own Chromium invocation is required, not optional: Chromium's internal setuid sandbox needs privilege escalation board-agent-worker.service's existing NoNewPrivileges = true already forbids, so it fails to start without it. Containment for whatever this renders (in practice: this repo's own already-committed, already-reviewed HTML, never third-party content) comes entirely from the outer systemd sandbox instead of Chromium's own process isolation — a deliberate trade-off, not an oversight. A failed screenshot call (e.g. an unanticipated sandbox restriction blocking Chromium) is explicitly framed in the prompt as a review uncertainty to note, not a review failure — the Reviewer keeps evaluating the rest of the diff normally either way.

Deliberately not built here: a computed WCAG contrast check (the same report's other uncertainty) — left for a separate follow-up if wanted, since it's a different kind of tool (deterministic math, no new dependency) than this one; and any visual verification for Codex/OpenRouter reviews.

Auth

The Traefik route for this service (intent-board in ../traefik/routes-internal.nix) already applies the auth BasicAuth middleware — it was the first internal route to opt into it, per an earlier review finding that a write-capable trigger endpoint needs more than the bare Tailnet boundary the read-only internal tools (tasks, schema) rely on. (An earlier version of this README claimed "no auth beyond the Tailnet boundary" — that was stale; the BasicAuth middleware predates this note.)

Real OIDC/Dex self-check auth (the pattern Forgejo/GoToSocial/Headscale already use) is deliberately deferred to a follow-up intent: this repo has no reusable oauth2-proxy forwardAuth mechanism today — verified 2026-09-06 that oauth2-proxy here is a singleton in front of exactly one other, currently-disabled backend (launcher), not a generic middleware. Wiring real self-check auth for this Web-UI means adding a new Dex client, a new LLDAP group scoped to the owner, and the check itself in trigger.py — not "point it at the existing oauth2-proxy."

Deliberately deferred / out of scope

  • No XMPP status post to update@muc.0ff.space. journalctl -u board-agent-worker/-u board-agent-completion-watcher remain the mandatory verification channels.
  • Board state stays in BOARD_STATE_DIR/state.json, not git-versioned docs/board/<slug>.yaml files — see the intent document's Phase 2 note. Keeps the trigger/worker/watcher services from ever writing into the same tracked files a human or another agent role might be editing in the same checkout at the same time.
  • Planner-driven model-profile escalation (a stronger model per job, with justification) from the same intent document — no structured job-metadata mechanism exists for board cards to carry that yet.

Tests

test_board_agent.py — same unittest/load_module pattern as ../agent-codex/test_agent_codex.py. Local:

nix-shell -p python3 --run \
  "cd hosts/blunix/localServices/board-agent && python3 -m unittest test_board_agent.py -v"