{ 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 - -" ]; }