442 lines
23 KiB
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 - -"
|
|
];
|
|
}
|