| .. | ||
| board.html | ||
| completion_watcher.py | ||
| failure_handler.py | ||
| fanout.py | ||
| kanban-operator-smoke-test-split-2-board-agent.md | ||
| kanban-operator-smoke-test-split-board-agent.md | ||
| metrics.py | ||
| metrics_baseline.py | ||
| orchestration.py | ||
| README.md | ||
| screenshot.py | ||
| state_store.py | ||
| telemetry.py | ||
| test_board_agent.py | ||
| test_board_browser.cjs | ||
| test_telemetry.py | ||
| test_workflow_safety.py | ||
| trigger.py | ||
| worker.py | ||
| workflow.py | ||
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 Buildercard 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
Splitcard action, only once a card is stuck atlast_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-RESULTobject must name that commit and sayapproveorreject. 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>#blunixsource, never a mutable checkout or whatever later reachedorigin/dev. Its receipt records the full revision and/run/current-systemclosure 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 toresults-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-thinkingdeepseek/deepseek-v3.2cohere/north-mini-code:freenvidia/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-watcherremain the mandatory verification channels. - Board state stays in
BOARD_STATE_DIR/state.json, not git-versioneddocs/board/<slug>.yamlfiles — 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"