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

799 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```bash
nix-shell -p python3 --run \
"cd hosts/blunix/localServices/board-agent && python3 -m unittest test_board_agent.py -v"
```