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.nix

442 lines
23 KiB
Nix

{ config, pkgs, ... }:
let
# Its own state tree, own CODEX_HOME copy and own queue for the job types
# it still executes itself in-process (review, research, split -- the
# pilot's original admin.md invocation is gone, replaced entirely by the
# handoff below, since the new intent explicitly wants the Builder role,
# not the Administrator, for the "builder" transition). Per
# docs/intent/git-intent-board-feedback.md the "builder" job type is
# deliberately NOT self-contained: it hands off into
# ../agent-codex's existing builder-inbox queue (AGENT_BUILDER_QUEUE_DIR
# below) instead of duplicating that already-built execution path --
# a considered exception to the original pilot's "no dependency on
# agent-codex" scope note (docs/intent/kanban-board-admin-trigger.md,
# since superseded), not an oversight.
stateDir = "/var/lib/board-agent";
repoDir = "/home/drone/nixos-config";
# Same convention as ../agent-codex.nix's roleDir: `-C` into agents/ so
# Codex's AGENTS.md loader is what it reads first, then the fixed prompt
# in worker.py names the target role (reviewer.md for review jobs) to
# follow from there.
roleDir = "${repoDir}/agents";
intentDir = "${repoDir}/docs/intent";
researchIntentDir = "${repoDir}/docs/research-intent";
codexHome = "${stateDir}/codex-home";
agentHome = "${stateDir}/home";
# completion_watcher.py does `import worker` (a sibling-module reuse of
# worker.py's state-file/job-writing helpers, kept in-tree since both
# live in board-agent/ already) -- Nix must copy all three scripts into
# the same store directory for that import to resolve at runtime,
# exactly the pattern ../agent-codex.nix's agentCodexScripts already
# uses for worker.py/builder.py's `import common`/`import db`.
boardAgentScripts = pkgs.runCommand "board-agent-scripts" { } ''
install -Dm444 ${./board-agent/state_store.py} "$out/state_store.py"
install -Dm444 ${./board-agent/workflow.py} "$out/workflow.py"
install -Dm444 ${./board-agent/trigger.py} "$out/trigger.py"
install -Dm444 ${./board-agent/worker.py} "$out/worker.py"
install -Dm444 ${./board-agent/failure_handler.py} "$out/failure_handler.py"
install -Dm444 ${./board-agent/completion_watcher.py} "$out/completion_watcher.py"
# trigger.py, worker.py and completion_watcher.py each `import fanout`
# -- same same-directory sibling-module reuse as completion_watcher.py's
# own `import worker` -- see docs/intent/board-subintent-fanout-synthesis.md.
install -Dm444 ${./board-agent/fanout.py} "$out/fanout.py"
install -Dm444 ${./board-agent/orchestration.py} "$out/orchestration.py"
# trigger.py, worker.py and completion_watcher.py also each `import
# metrics` -- docs/intent/board-intent-run-metrics-baseline.md's opt-in
# run_history logging, same sibling-module reuse as fanout.py above.
# metrics_baseline.py is the standalone pilot tool from that same
# intent; it is never started by systemd (see BOARD_METRICS_BASELINE_SCRIPT
# below and this file's README), just installed read-only alongside
# the scripts it imports (`import metrics`, `import trigger`, `import
# worker`) so an operator can run it against this exact store path.
install -Dm444 ${./board-agent/metrics.py} "$out/metrics.py"
install -Dm444 ${./board-agent/metrics_baseline.py} "$out/metrics_baseline.py"
# Automatic, board-wide machine telemetry. Kept separate from the
# opt-in metrics.py baseline pilot and persisted in SQLite/WAL.
install -Dm444 ${./board-agent/telemetry.py} "$out/telemetry.py"
# Read-only nix-store copy of the Reviewer's Claude Code permission
# settings -- same self-modification-prevention reasoning as
# ../agent-codex.nix's builder-settings.json: never read live from
# agents/claude-settings/reviewer.json in the checkout, since that
# repo is reachable (read-only, but still) from a review run.
install -Dm444 ${../../../agents/claude-settings/reviewer.json} "$out/reviewer-settings.json"
install -Dm444 ${../../../agents/claude-settings/researcher.json} "$out/researcher-settings.json"
install -Dm444 ${../../../agents/claude-settings/planner.json} "$out/planner-settings.json"
install -Dm444 ${../../../agents/claude-settings/operator.json} "$out/operator-settings.json"
# docs/intent/board-reviewer-visual-verification.md: the Reviewer's
# fixed, narrowly-scoped headless-Chromium screenshot helper -- see
# BOARD_SCREENSHOT_SCRIPT/BOARD_CHROMIUM_BIN below and the script's own
# docstring for why this is one fixed script instead of raw chromium
# access.
install -Dm444 ${./board-agent/screenshot.py} "$out/screenshot.py"
'';
triggerScript = "${boardAgentScripts}/trigger.py";
workerScript = "${boardAgentScripts}/worker.py";
failureHandlerScript = "${boardAgentScripts}/failure_handler.py";
completionWatcherScript = "${boardAgentScripts}/completion_watcher.py";
boardHtml = ./board-agent/board.html;
httpHost = "127.0.0.1";
httpPort = 8060;
# Same owner as ../agent-codex.nix's builderOwner -- duplicated rather
# than shared, matching this stack's existing standalone-copy
# convention (see append_model_options() in worker.py).
builderOwner = "lukas@${config.domain}";
# ../agent-codex's builder-inbox queue dir -- board's own worker.py
# writes translated builder jobs directly here instead of running Codex
# itself for that job type.
agentCodexBuilderQueueDir = "/var/lib/agent-codex/builder-inbox";
# Dedicated inbox for the board's "leicht" column -- exclusively watched
# by ../agent-codex.nix's agent-codex-builder-2 instance, never raced
# with the shared inbox above (see
# docs/intent/board-builder-weighted-lanes.md and worker.py's
# LANE_QUEUE_ENV).
agentCodexBuilderQueueDirLeicht = "/var/lib/agent-codex/builder-inbox-leicht";
agentCodexBuilderProcessingDir = "/var/lib/agent-codex/builder-processing";
agentCodexBuilderProcessingDirLeicht = "/var/lib/agent-codex/builder-processing-leicht";
builderResultsDir = "${stateDir}/builder-results";
builderResultsConsumedDir = "${stateDir}/builder-results-consumed";
agentCodexDeployQueueDir = "/var/lib/agent-codex/builder-deploy-inbox";
prepareCodexHome = pkgs.writeShellScript "prepare-board-agent-codex-home" ''
set -eu
${pkgs.coreutils}/bin/install -d -m 700 ${codexHome} ${agentHome}
if [ ! -s ${codexHome}/auth.json ]; then
${pkgs.coreutils}/bin/install \
-m 600 \
/home/drone/.codex/auth.json \
${codexHome}/auth.json
fi
'';
commonEnvironment = {
BOARD_STATE_DIR = stateDir;
BOARD_STATE_FILE = "${stateDir}/state.json";
BOARD_INBOX_DIR = "${stateDir}/inbox";
BOARD_PROCESSING_DIR = "${stateDir}/processing";
BOARD_DONE_DIR = "${stateDir}/done";
BOARD_FAILED_DIR = "${stateDir}/failed";
# Single fixed lock file, held by worker.py for the duration of each
# selected reviewer call -- see acquire_repo_lock() in worker.py. One repo,
# one lock; no per-repo keying needed here unlike
# ../agent-codex.nix's AGENT_BUILDER_LOCK_DIR.
BOARD_LOCK_FILE = "${stateDir}/board-agent.lock";
BOARD_REPO_DIR = repoDir;
BOARD_INTENT_DIR = intentDir;
BOARD_RESEARCH_INTENT_DIR = researchIntentDir;
BOARD_ROLE_DIR = roleDir;
# docs/intent/board-intent-run-metrics-baseline.md: comma-separated
# allowlist of intent/research-intent slugs enrolled in the run-metrics
# pilot. Empty by default -- metrics.record_run() is a no-op for any
# slug not listed here, so leaving this empty keeps every card's
# state.json entry exactly as it was before this intent. Edit this
# value directly (not variables.nix) when starting or ending a pilot
# slug, matching this file's existing convention for one-off tunables
# like BOARD_REVIEWER_CODEX_MODEL below rather than the deployment-wide
# domain/IP values variables.nix carries.
BOARD_METRICS_SLUGS = "";
# Resolved path to metrics_baseline.py's store copy, so an operator
# never has to hunt for it -- see this file's README for how to run it
# with the rest of these BOARD_* variables (systemctl show
# board-agent-worker.service --property=Environment).
BOARD_METRICS_BASELINE_SCRIPT = "${boardAgentScripts}/metrics_baseline.py";
BOARD_TELEMETRY_DB = "${stateDir}/telemetry.sqlite3";
# docs/intent/board-reviewer-visual-verification.md: lets the Claude
# Reviewer render an HTML/CSS change headless (light + dark
# prefers-color-scheme) and look at the result via its own Read tool,
# instead of reasoning about colors from source alone -- verified live
# 2026-09-10 that the two docs/review "Unsicherheiten" bullets this
# closes (no visual render, no computed contrast plausibility check)
# were real gaps, not hypothetical ones.
#
# Only wired into the Claude branch of run_review() -- Codex's
# `--sandbox read-only` blocks every write, including into /tmp, so
# Chromium has nowhere to put a screenshot there at all (see this
# directory's README, "Why read-only, not a scoped writable dir").
#
# --no-sandbox is required in screenshot.py's own chromium invocation:
# Chromium's internal setuid sandbox needs privilege escalation this
# service's NoNewPrivileges=true already forbids, so it would fail to
# start without it. Containment for whatever this renders (in
# practice: this repo's own reviewed commits, never third-party or
# user-supplied content) comes entirely from the outer systemd sandbox
# instead, not from Chromium's own process isolation -- a real,
# deliberate trade-off, not an oversight.
BOARD_CHROMIUM_BIN = "${pkgs.chromium}/bin/chromium";
BOARD_SCREENSHOT_SCRIPT = "${boardAgentScripts}/screenshot.py";
BOARD_CODEX_BIN = "${pkgs.codex}/bin/codex";
BOARD_HOME = agentHome;
BOARD_CODEX_HOME = codexHome;
# Since 2026-09-07 (the Reviewer moved off Codex the day after the
# Builder, same exhausted-quota reason). Deliberately no dedicated
# identity/CLAUDE_CONFIG_DIR -- shares the interactive drone user's
# own Claude Code login/quota, same tradeoff as
# ../agent-codex.nix's AGENT_BUILDER_CLAUDE_*.
BOARD_CLAUDE_BIN = "${pkgs.claude-code}/bin/claude";
BOARD_REVIEWER_CLAUDE_SETTINGS = "${boardAgentScripts}/reviewer-settings.json";
BOARD_RESEARCHER_CLAUDE_SETTINGS = "${boardAgentScripts}/researcher-settings.json";
BOARD_PLANNER_CLAUDE_SETTINGS = "${boardAgentScripts}/planner-settings.json";
BOARD_OPERATOR_CLAUDE_SETTINGS = "${boardAgentScripts}/operator-settings.json";
# Deliberately below agents/catalog.json's model_profiles.reviewer
# profile (opus/high) -- that profile is for the general Herdr-visible
# reviewer role reviewing arbitrary requests. The board Reviewer only
# ever reviews a small, already-scoped diff produced from a vetted
# docs/intent/*.md, so a cheaper model catching obvious problems is an
# acceptable trade for cost (2026-09-07 decision). haiku/low was tried
# first and failed live (2026-09-07, board-show-commit-on-card): it
# didn't reliably follow the required ===REVIEW-REPORT=== output
# format. sonnet/low is the next-cheapest step down from opus/high.
BOARD_REVIEWER_CODEX_MODEL = "gpt-5.6-luna";
BOARD_REVIEWER_CODEX_REASONING_EFFORT = "none";
BOARD_REVIEWER_CLAUDE_MODEL = "sonnet";
BOARD_REVIEWER_CLAUDE_REASONING_EFFORT = "low";
BOARD_RESEARCHER_CLAUDE_MODEL = "sonnet";
BOARD_RESEARCHER_CLAUDE_REASONING_EFFORT = "medium";
# Split runs happen rarely (a card must hang at the iteration limit
# twice first) but each one has to make a real architectural judgment
# call, so unlike the Reviewer's deliberately cheap profile above this
# reuses agents/catalog.json's planner profile (gpt-5.6-terra/high,
# claude-sonnet-5/high) rather than a cheaper one -- see
# docs/intent/board-builder-limit-reached-auto-split.md.
BOARD_PLANNER_CODEX_MODEL = "gpt-5.6-terra";
BOARD_PLANNER_CODEX_REASONING_EFFORT = "high";
BOARD_PLANNER_CLAUDE_MODEL = "sonnet";
BOARD_PLANNER_CLAUDE_REASONING_EFFORT = "high";
# OpenRouter is a separate board runtime backed by Codex's
# OpenAI-compatible Responses harness. The worker reads this age path
# only when that runtime is selected and exports the value solely to the
# spawned Codex process as OPENROUTER_API_KEY.
BOARD_OPENROUTER_API_KEY_FILE = config.age.secrets.openrouterApiKey.path;
BOARD_REVIEWER_OPENROUTER_MODEL = "deepseek/deepseek-v3.2";
BOARD_REVIEWER_OPENROUTER_REASONING_EFFORT = "";
BOARD_RESEARCHER_OPENROUTER_MODEL = "deepseek/deepseek-v3.2";
BOARD_RESEARCHER_OPENROUTER_REASONING_EFFORT = "";
BOARD_PLANNER_OPENROUTER_MODEL = "deepseek/deepseek-v3.2";
BOARD_PLANNER_OPENROUTER_REASONING_EFFORT = "";
# The Operator makes the high-leverage direct-vs-split decision, so it
# deliberately starts with the same planning-grade profile as Planner.
# Child Builder profiles may be cheaper, but only after this decision.
BOARD_OPERATOR_CODEX_MODEL = "gpt-5.6-terra";
BOARD_OPERATOR_CODEX_REASONING_EFFORT = "high";
BOARD_OPERATOR_CLAUDE_MODEL = "sonnet";
BOARD_OPERATOR_CLAUDE_REASONING_EFFORT = "high";
BOARD_OPERATOR_OPENROUTER_MODEL = "deepseek/deepseek-v3.2";
BOARD_OPERATOR_OPENROUTER_REASONING_EFFORT = "";
BOARD_OPERATOR_MAX_CONCURRENT_BUILDER_JOBS = "2";
BOARD_CODEX_TIMEOUT_SECONDS = "1200";
BOARD_CLAUDE_TIMEOUT_SECONDS = "1200";
BOARD_RUNTIME_DEFAULT = "codex";
# Each of the two board Builder columns ("schwer"/"in_umsetzung" and
# "leicht"/"in_umsetzung_leicht") now maps 1:1 to its own dedicated
# agent-codex-builder-1/-2 instance and its own inbox -- see
# docs/intent/board-builder-weighted-lanes.md. builder_trigger_blocked()
# in trigger.py enforces the resulting cap (1 in-flight job per lane)
# directly from queued_jobs()'s "lane" field, so the old single global
# BOARD_MAX_CONCURRENT_BUILDER_JOBS knob (docs/intent/board-builder-worktree-parallelism.md)
# is gone -- there is no longer one shared number to configure.
BOARD_BUILDER_OWNER_JID = builderOwner;
AGENT_BUILDER_QUEUE_DIR = agentCodexBuilderQueueDir;
AGENT_BUILDER_QUEUE_DIR_LEICHT = agentCodexBuilderQueueDirLeicht;
AGENT_BUILDER_PROCESSING_DIR = agentCodexBuilderProcessingDir;
AGENT_BUILDER_PROCESSING_DIR_LEICHT = agentCodexBuilderProcessingDirLeicht;
BOARD_BUILDER_RESULTS_DIR = builderResultsDir;
BOARD_BUILDER_RESULTS_CONSUMED_DIR = builderResultsConsumedDir;
AGENT_BUILDER_DEPLOY_QUEUE_DIR = agentCodexDeployQueueDir;
XDG_CACHE_HOME = "${stateDir}/xdg-cache";
XDG_DATA_HOME = "${stateDir}/xdg-data";
};
in
{
# Long-running static frontend + JSON trigger API. Tailnet-only reach
# comes from binding to loopback and only being exposed via the internal
# Traefik entrypoint (see traefik/routes-internal.nix), which already
# applies the "auth" basicAuth middleware to this specific route (the
# first internal route to opt into it -- see that file's comment). A
# write-capable trigger endpoint needs more than the bare Tailnet
# boundary the read-only internal tools (tasks, schema) rely on.
# Real OIDC/Dex self-check (matching Forgejo/GoToSocial/Headscale's
# pattern) is deliberately deferred to a follow-up intent -- this repo
# has no reusable oauth2-proxy forwardAuth mechanism today (verified
# 06.09.2026: oauth2-proxy here is a singleton in front of one other,
# currently-disabled backend), so BasicAuth stays for this phase.
systemd.services.board-agent-trigger = {
description = "Tailnet-only intent-lifecycle kanban board (frontend + trigger API)";
wantedBy = [ "multi-user.target" ];
before = [ "traefik.service" ];
environment = commonEnvironment // {
BOARD_HTTP_HOST = httpHost;
BOARD_HTTP_PORT = toString httpPort;
BOARD_HTML_PATH = boardHtml;
};
serviceConfig = {
Type = "simple";
User = "drone";
Group = "users";
StateDirectory = "board-agent";
StateDirectoryMode = "0700";
UMask = "0077";
ExecStart = "${pkgs.python3}/bin/python3 ${triggerScript}";
Restart = "always";
RestartSec = "5s";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
# New with docs/intent/board-subintent-fanout-synthesis.md:
# update_phase() calls fanout.maybe_synthesize() after every phase
# change, since a Builder-flow child's terminal move
# (bereit_fuer_deployment/planung_mit_feedback/deployed/archiviert)
# only ever happens via a human PATCH through this always-on service,
# never through the oneshot worker. A proportionate expansion of an
# already write-capable, BasicAuth-protected, owner-only-in-practice
# service -- it can already cause arbitrary git commits via the
# existing Builder-trigger path.
#
# docs/review, docs/research and docs/research-intent added for the
# bulk "Smoke-Tests entfernen" header button (delete_smoke_test_cards()
# in trigger.py): verified live 2026-09-10 that DELETE
# /api/board/smoke-tests died with "OSError: [Errno 30] Read-only file
# system" on docs/review, taking the whole request down (the
# unhandled exception closes the connection mid-response, which
# Traefik then surfaces as a 502) -- this service previously only had
# docs/intent writable, even though the cleanup unlinks files across
# all four corpus/output directories.
ReadWritePaths = [
# Every state transaction may replay another wrapper's durable outbox.
# Keep publication permissions identical for all trusted state writers.
agentCodexBuilderQueueDir
agentCodexBuilderQueueDirLeicht
agentCodexDeployQueueDir
"${repoDir}/docs/intent"
"${repoDir}/docs/research-intent"
"${repoDir}/docs/review"
"${repoDir}/docs/research"
];
};
};
# Path-triggered oneshot: dispatches queued board jobs (worker.py).
# "builder" jobs are just a fast queue-file translation into
# agent-codex's builder-inbox (ReadWritePaths for that specific
# directory below) -- no model call happens here for that job type.
# "review" jobs use the snapshotted runtime: Codex is sandboxed read-only;
# Claude has all write tools disabled. The wrapper writes the report, so
# only docs/review plus Claude's own session state are writable.
systemd.services.board-agent-worker = {
description = "Dispatch a triggered board job (builder hand-off or review)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = commonEnvironment;
path = [ pkgs.git ];
serviceConfig = {
Type = "oneshot";
User = "drone";
Group = "users";
WorkingDirectory = roleDir;
StateDirectory = "board-agent";
StateDirectoryMode = "0700";
UMask = "0077";
ExecStartPre = prepareCodexHome;
ExecStart = "${pkgs.python3}/bin/python3 ${workerScript}";
ExecStopPost = "${pkgs.python3}/bin/python3 ${failureHandlerScript}";
TimeoutStartSec = "20min";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
# "/home/drone/.claude" is the same, identical exception as
# ../agent-codex.nix's agent-codex-builder.service: Claude Code
# needs to write its own session/cache state there when sharing the
# interactive drone user's identity instead of an isolated one.
ReadWritePaths = [
agentCodexBuilderQueueDir
agentCodexBuilderQueueDirLeicht
agentCodexDeployQueueDir
"${repoDir}/docs/review"
"${repoDir}/docs/research"
# run_split() (job_type "split") writes each proposed child intent
# here -- see docs/intent/board-builder-limit-reached-auto-split.md.
"${repoDir}/docs/intent"
"/home/drone/.claude"
];
};
};
systemd.paths.board-agent-worker = {
description = "Watch the board-agent trigger inbox";
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathExistsGlob = "${stateDir}/inbox/*.json";
Unit = "board-agent-worker.service";
};
};
# Path-triggered oneshot: reacts to completion records
# ../agent-codex/builder.py writes for board-originated jobs (see
# enqueue_builder_job() in worker.py) and queues the follow-up review
# job -- see completion_watcher.py's module docstring for the full
# decision table. A limit_reached completion also queues one automatic
# retry (enqueue_builder_retry_job()) via the same worker.enqueue_builder_job()
# board-agent-worker uses above -- that write lands in
# agent-codex's builder-inbox, not this service's own StateDirectory, so
# it needs the identical ReadWritePaths entry (found live 2026-09-08: a
# limit_reached run's automatic retry silently failed to queue with
# "[Errno 30] Read-only file system", since this ReadWritePaths was
# missing -- ProtectSystem = "strict" blocked the write with no error
# surfaced anywhere but this service's own journal).
systemd.services.board-agent-completion-watcher = {
description = "React to a finished board-triggered Builder run";
environment = commonEnvironment;
serviceConfig = {
Type = "oneshot";
User = "drone";
Group = "users";
StateDirectory = "board-agent";
StateDirectoryMode = "0700";
UMask = "0077";
ExecStart = "${pkgs.python3}/bin/python3 ${completionWatcherScript}";
TimeoutStartSec = "1min";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
ReadWritePaths = [
agentCodexBuilderQueueDir
agentCodexBuilderQueueDirLeicht
# Successful completion of the final child may create the
# idempotent synthesis intent before queuing its automatic run.
"${repoDir}/docs/intent"
agentCodexDeployQueueDir
];
};
};
systemd.paths.board-agent-completion-watcher = {
description = "Watch for finished board-triggered Builder runs";
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathExistsGlob = [ "${builderResultsDir}/*.json" "${stateDir}/results-processing/*.json" ];
Unit = "board-agent-completion-watcher.service";
};
};
# agent-codex-builder.service's ReadWritePaths for builderResultsDir
# (../agent-codex.nix) needs this to already exist at service start --
# systemd sets up ReadWritePaths bind-mounts as part of namespace setup
# before any ExecStartPre runs, so a lazily-created directory (e.g. via
# completion_watcher.py's/builder.py's own mkdir calls) is too late and
# fails the whole unit with "Failed to set up mount namespacing: ...:
# No such file or directory" (found live 06.09.2026, first real
# board-triggered Builder job). tmpfiles guarantees it exists
# independent of which service starts first.
systemd.tmpfiles.rules = [
"d ${builderResultsDir} 0700 drone users - -"
"d ${builderResultsConsumedDir} 0700 drone users - -"
"d ${agentCodexBuilderQueueDir} 0700 drone users - -"
"d ${agentCodexBuilderQueueDirLeicht} 0700 drone users - -"
"d ${agentCodexBuilderProcessingDir} 0700 drone users - -"
"d ${agentCodexBuilderProcessingDirLeicht} 0700 drone users - -"
"d ${agentCodexDeployQueueDir} 0700 drone users - -"
];
}