The agent runner of the pinboard/foreman pair. It polls pinboard for queued tasks, runs each one as a Claude Code session in its own clone of the repository, opens a pull request, and then watches CI, merges and deploys according to the task's autonomy level. It only ever calls out (to pinboard, GitHub and the machines it deploys to); nothing calls into it.
Scope. This is the author's homelab tooling, published in case it is useful.
Installing: see pinboard's README. Its quickstart compose runs pinboard
and foreman's dispatch loop together from published images. This README
covers foreman's own internals, and the full runner set (dispatch, chat,
pipeline, housekeeping) in this repo's docker-compose.yml for a
dedicated machine.
This repo's own deployment, for scripts/deploy.sh and
scripts/healthcheck.sh:
- Hostname:
foreman - Working directory:
/app
This rebuilds foreman on any Linux machine with Docker, from this README alone. Nothing here assumes Proxmox, an LXC, systemd, a tailnet or a secret store; where the operator's own install adds something, it says so.
foreman is the three runner services in docker-compose.yml (dispatch,
chat, pipeline) plus a nightly housekeeping job, all one image built
from this repo. Everything below is outside it:
| Dependency | What foreman uses it for | Without it |
|---|---|---|
Docker Engine with the compose plugin, socket at /var/run/docker.sock |
Runs the services. Agent runs also use this same daemon for their own docker compose |
Nothing runs |
A pinboard instance at PINBOARD_URL (default http://pinboard:8095), with a project per repo (localPath, repoUrl) and at least one agent and license |
The task queue. All three services poll it; foreman calls out to it, nothing calls in | healthcheck.sh fails at work task list; no task is ever dispatched |
A Claude credential: ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from claude setup-token |
Every agent run and chat turn | Runs refuse to start, naming the missing variable (spawn-env.ts) |
A GitHub account and a personal access token in GH_TOKEN (repo and workflow access to every repo foreman works on) |
gh pr create, CI status, merge; every git fetch and push to GitHub, in the containers and on the host ("GitHub access" below) |
Runs finish but cannot push or open a PR; the pipeline cannot merge |
An SSH key pair in state/home/.ssh, public half in authorized_keys on every machine foreman deploys to (not on GitHub, a past task) |
SSH from deploy-service.sh, work root |
Deploys fail at SSH; ordinary tasks are unaffected |
A homelab workspace repo at $WORK_HOMELAB_ROOT/homelab (default: the homelab checkout under whichever /app/workspace/<org>/ holds one): AGENTS.md, scripts/deploy-service.sh, scripts/leak-check.sh, public-leak-patterns.txt, targets/ |
The "read the conventions" prompt line, post-merge deploys, the leak check before pushing to a public repo | No conventions line; a merged task is left for a manual deploy; pushes to a repo in PUBLIC_GITHUB_ORGS are blocked |
Optional, homelab-only: EIGHT_BALL_URL/EIGHT_BALL_API_KEY (only for
agents whose executor is the LLM gateway), secrets/global.env rendered by a secret store
(read after .env), a host crontab (nightly housekeeping and the Claude
rules mirror), a host ~/.claude to mirror into the containers, and
rootproxy on a Proxmox host for work root (WORK_ROOT_HOST, default
root@homelab). Each is skipped cleanly when absent, and dispatch, chat
and pipeline log one line per integration at startup (claude:,
github:, llm gateway:, workspace conventions:, deploys:) saying which are
on.
.env.example is the contract; it marks each variable required or
optional. The variables, by who reads them:
| Variable | Required | Read by |
|---|---|---|
ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN |
One of the two, unless every pinboard license names its own tokenEnvVar |
spawn-env.ts; stripped from what agents inherit |
CLAUDE_OAUTH_TOKEN_<SEAT> |
Only for a license whose tokenEnvVar names it |
spawn-env.ts |
GH_TOKEN |
Yes, for anything beyond dry runs | gh, and git through it (also on the host, "GitHub access") |
PINBOARD_URL |
If pinboard is not at http://pinboard:8095 |
work, deploy.sh, sweep-orphaned-worktrees.sh |
DOCKER_GID |
If the host's docker group is not gid 991 (getent group docker | cut -d: -f3) |
Image build; without the right gid the containers cannot use the socket |
WORK_TASK_ROOT |
No (default /app/tasks) |
Task clones; mounted at the identical path inside and out |
FOREMAN_WORKSPACE_DIR |
No (default ./workspace) |
Host path of the workspace clone |
WORK_HOMELAB_ROOT |
No (default: the /app/workspace/<org> that holds a homelab checkout) |
Where the homelab repo above is looked up |
PUBLIC_GITHUB_ORGS |
No (default rackmarten; empty for none) |
Leak check and PR text ("Public repositories") |
HOMELAB_USER |
No (default marek) |
SSH user for deploys and releases |
CLAUDE_MODEL, EIGHT_BALL_URL, EIGHT_BALL_API_KEY, EIGHT_BALL_PROJECT, FOREMAN_MIN_FREE_GB, FOREMAN_CI_TIMEOUT_MINUTES |
No | Model choice, LLM gateway executor (and the pinboard project its outage alert is filed under), disk-space guard, how long the pipeline waits for a PR's CI (default 30 minutes) |
FOREMAN_HOST_SSH, HOUSEKEEPING_SCHEDULE, CLAUDE_RULES_SCHEDULE |
No | deploy.sh when foreman deploys itself, cron schedules |
NTFY_URL, NTFY_TOKEN_PINBOARD |
No (without both, the pinboard watchdog only logs) | Pinboard watchdog alerts; PINBOARD_WATCHDOG_NTFY_TOKEN_VAR names a different token variable |
PINBOARD_WATCHDOG, PINBOARD_WATCHDOG_INTERVAL_SECONDS, PINBOARD_WATCHDOG_OUTAGE_MINUTES, PINBOARD_WATCHDOG_MAX_RUNS_PER_DAY, PINBOARD_WATCHDOG_NTFY_TOPIC, PINBOARD_WATCHDOG_STATE_FILE |
No (off disables it; defaults 30, 10, 2, pinboard, ~/.foreman/pinboard-watchdog.json) |
Pinboard watchdog ("Pinboard watchdog" below) |
Bootstrap-only (environment of scripts/bootstrap-container-state.sh, not
.env): SSH_KEY, GIT_USER_NAME, GIT_USER_EMAIL, SEED_HOME,
CONTAINER_UID.
Mounts (docker-compose.yml), all relative to the checkout unless set
otherwise:
| Host | Container | Holds |
|---|---|---|
/var/run/docker.sock |
same | The host's Docker daemon |
${WORK_TASK_ROOT:-/app/tasks} |
same path | Per-task clones. Must be the identical path, because agents' own bind mounts resolve on the host |
${FOREMAN_WORKSPACE_DIR:-./workspace} |
/app/workspace |
The workspace clone, <org>/<repo> |
./state |
/app/state |
HOME (state/home): .claude, .ssh, .gitconfig; logs |
./secrets |
/app/secrets (read-only) |
Optional global.env |
-
Host. Install Docker Engine with the compose plugin, git and crontab (optional). Create a user with uid 1000 in the
dockergroup; the containers run as uid 1000, and running the steps below as that user is what gets file ownership right without achown. -
Clone this repo to
/app(any path works, butWORK_TASK_ROOTdefaults to/app/tasks, so pick another checkout path only together with settingWORK_TASK_ROOT):git clone https://github.com/rackmarten/foreman.git /app cd /app -
Configure.
cp .env.example .env, then set the Claude credential,GH_TOKEN,PINBOARD_URLandDOCKER_GID(see the table above). -
SSH key and git identity. Put the key pair foreman should use at
~/.ssh/id_ed25519(or exportSSH_KEY=<path>), and either have a global git identity or exportGIT_USER_NAME/GIT_USER_EMAIL. Add the public key to every deploy target. GitHub does not need it: git reaches GitHub over https withGH_TOKEN("GitHub access" below). -
Restore or seed state. With a backup, restore
state/andtasks/(see "State layout" below) first. Then./scripts/bootstrap-container-state.shfills in only what is missing:state/homewith the SSH key,known_hosts,.gitconfigand a copy of~/.claude(no login), and an emptyworkspace/. -
Workspace. Clone each repo a pinboard project points at as
workspace/<org>/<repo>, matching that project'slocalPath. For deploys and the leak check, clone the homelab repo toworkspace/<org>/homelab(or setWORK_HOMELAB_ROOT). -
Start:
./scripts/deploy.sh. It seeds again (a no-op now), builds the image, bringsdispatch,chatandpipelineup one at a time, and installs the cron entries wherecrontabexists. A baredocker compose up -dalso works on a fresh machine, since nothing is busy yet. -
Verify:
./scripts/healthcheck.shpasses whendispatchandpipelineare running andwork task listreaches pinboard from inside the image. Then read the integration lines:docker compose logs dispatch | head -20. A Claude smoke test:docker compose run --rm dispatch claude -p 'reply with the single word ok'.
What this rebuild does not bring back: in-flight task attempts whose
tasks/ was not restored (requeue those tasks in pinboard), and chat sessions
without their transcripts in state/home/.claude/projects (they start
fresh rather than resuming).
Since a past task foreman needs nothing but Docker:
cp .env.example .env # set ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN) and GH_TOKEN
./scripts/deploy.sh
- One required secret pair. A Claude credential,
ANTHROPIC_API_KEYorCLAUDE_CODE_OAUTH_TOKENfromclaude setup-tokenfor subscription auth, is used by every run whose pinboard license names no token variable of its own (spawn-env.ts); a license withtokenEnvVarset never falls back to it.GH_TOKENlets runs push and open pull requests. - Everything else is optional.
dispatch,chatandpipelineeach log one line per integration when they start (claude:,github:,llm gateway:,workspace conventions:,deploys:), saying which are on. - Where things live. The workspace clone is
./workspaceunlessFOREMAN_WORKSPACE_DIRpoints elsewhere; task workdirs areWORK_TASK_ROOT. The homelab conventions file anddeploy-service.share looked up underWORK_HOMELAB_ROOT(default: the/app/workspace/<org>holding ahomelabcheckout); without them a run skips the conventions line and a merged task is left for a manual deploy. - Host pieces are skipped when absent.
scripts/deploy.shretires systemd units only where they exist, installs its cron entries only wherecrontabexists, and mirrors~/.claudeinto the containers only where the host has one. On a host that has them all of that still happens, andsecrets/global.env(from a secret store) is still read after.env.
Since a past task foreman's autonomous runner is a Docker
Compose deployment like the rest of the org: three long-running services
from one locally built image (foreman-runner:local), plus a housekeeping
job behind a compose profile.
| Service | Command | Replaced |
|---|---|---|
dispatch |
work dispatch |
foreman-dispatch.service |
chat |
work chat daemon |
foreman-chat.service |
pipeline |
work pipeline watch |
foreman-pipeline.service |
housekeeping (profile, run nightly by a host crontab entry) |
scripts/housekeeping.sh --apply |
foreman-housekeeping.timer |
scripts/deploy.sh builds the image and brings each service up on it,
one at a time and never while it is busy (see "Deploying" below);
scripts/healthcheck.sh checks dispatch and pipeline are running and
that work task list reaches pinboard from the image. No systemd --user
unit runs any of this any more; "Rollback to the systemd units" below is
the way back. The image, mounts and state layout are described in
"Container image" below.
Around that, the LXC is still the operator's bare-metal base of operations:
-
Node.js 22 (
~/.local-node, the official Linux x64 tarball extracted under$HOME, not apt/NodeSource - deliberately rootless, sincemarekon this LXC only has prompted (not passwordless)sudo). Added toPATHvia~/.zshenv(not~/.zshrc-.zshenvis sourced for non-interactive shells too, which matters for plainssh foreman '<command>'invocations and VSCode Remote-SSH tasks, not just an interactive login). -
Claude Code (
npm install -g @anthropic-ai/claude-code, installed into that same Node tree). This is the second machine in the org with Claude Code and Claude credentials on it, aftersandbox- see the workspaceAGENTS.md's "Remote access policy" for why. Authentication itself is a manual step (claude setup-token, run once asmarekover SSH) - not something an agent session can complete for itself. -
A full clone of the homelab workspace (
~/projects/<org>/, every org repo cloned as a sibling ofhomelab/) - the "Git clones of the org" I2 asked for, structured identically to the WSL desktop's own workspace clone so the two stay interchangeable as a base of operations. -
SSH access to every other LXC in the org, as
marek(the same key pattern the desktop already uses -foreman's own~/.ssh/id_ed25519, appended to every other LXC'sauthorized_keysin I2). Seelxc-configurator/.env.example'sSSH_PUBLIC_KEYcomment - a future LXC provisioned from scratch should get both the desktop's and foreman's key from day one, not have foreman's appended by hand afterward like this first round was.foreman's own key. The containerized install also has an identity of its own, separate from the operator's key it was moved with:
state/home/.ssh/foreman_ed25519(ed25519, commentforeman-agent@foreman (foreman own key, not a person), fingerprintSHA256:pTdk9UrZRis7nxfTTnhDWVPscIZ3EDX7Qut9vIUm2eE). Its public half was appended, alongside the existing keys, to the deploy user'sauthorized_keyson every service container and checked per host withssh -i state/home/.ssh/foreman_ed25519 -o IdentitiesOnly=yes. It is not in root'sauthorized_keyson the Proxmox host, and foreman's SSH client still offersid_ed25519(the operator's key) by default; switching to the own key and retiring the operator's are a later, hand-run step. A new container'sSSH_PUBLIC_KEYshould list this key next to the operator's. Check a host has it withssh <host> ssh-keygen -lf ~/.ssh/authorized_keys. -
The
workCLI (app/) - a client for pinboard's API. Read-only:work task list [--project=] [--status=] [--type=] [--executor=],work task show <id>,work task next.task listalso takes--parent=<id>and--label=<name>(the label filter runs client-side). On a story,task showlists its children in run order (a child after every sibling itdepends_on, then by priority) with status, executor and any open blocked-by; on a child it names the story. Grooming (task #407):work task update <id> [--title= --description= --status= --type= --executor= --priority= --autonomy= --effort= --parent=<id>|none]PATCHes only the given fields, and--parentreplaces the existing parent relation;work task label <id> add|remove <name>resolves the label by name and creates it on add;work task createtakes repeatable--label=<name>and--depends-on=<id>. Zero-autonomy write path (I3):work task claim <id>,work task comment <id> <text...>,work task handoff <id> [--completed=] [--changed=] [--verified=] [--problems=] [--discovered=] [--next=] [--effort=] [--cost=] [--duration-ms=] [--turns=] [--agent=<agentName>] [--summary=] [--checklist=<item>]...(--checklistis repeatable, one item each),work task complete <id>- every write stampsactorType/actorNameprovenance (WORK_ACTOR_TYPE/WORK_ACTOR_NAMEenv vars, defaulthuman/the OS username) onto atask_eventsrow, per pinboard-initial-idea.md's "Agent Users / Provenance". Alsowork plan sync <project-slug> [--force], which writes a minimal generatedPLAN.md(current task, open tasks, latest handoff - nothing private) into that project's own repo under~/projects/<localPath>/(e.g.~/projects/my-org/pinboard/-WORK_WORKSPACE_ROOToverrides the base). Refuses to overwrite a file that isn't itself a previouswork plan syncoutput (checked via a marker comment on the first line) unless--forceis passed - this is what stops it from ever clobbering a hand-written plan like this project's or pinboard's ownPLAN.md. The services run the copy built into the image; on the host,workis annpm linkfor the operator (see "Local development" below). Talks to pinboard over the tailnet directly (PINBOARD_URL, defaulthttp://pinboard:8095), not through reverse-proxy.PINBOARD_URLis the origin; every API call goes to${PINBOARD_URL}/api/...(a past task). -
Agent routing (I12a,
app/src/routing.ts) picks which agent runs a task before the runner below (or the dispatcher) spawns anything.resolveAgentForTaskmatches pinboard'srouting_rules(task type and/or label, both nullable = wildcard) against the task, picks the highest-prioritymatch, ties broken by the matched agent's ownroutingWeight, and falls back toforeman-runnerwhen nothing matches- zero rules configured is a no-op.
work task run <id> --agent=<name>still pins a specific agent directly, skipping routing entirely. Which executor actually runs the task depends on the resolved agent's ownexecutorfield:"cli"is theclaude -ppipeline below;"8ball"(I12b,app/src/eightball.ts) is a separate, much simpler path for non-code tasks (research summaries, classification, digest writing) - no worktree, no git, no PR, just a free-text result from an LLM gateway's shared local models posted back as a comment/handoff. NeedsEIGHT_BALL_URL/EIGHT_BALL_API_KEYset, in.envor in/app/secrets/global.env(loaded by every service's secondenv_fileentry, seedocker-compose.yml); recreate the services after changing either - without them an 8ball-routed task fails with a clear error and goes torework, same as any other misconfigured agent. The budget gate (entirely about Claude's own $/window) is deliberately not consulted on this path, since a gateway run costs nothing - gating free local work during a Claude budget crunch would block it for no reason.
- zero rules configured is a no-op.
-
The agent runner (
work task run <id>, I4a) spawns an unattendedclaude -psession against a task and, once it finishes, pushes its branch and opens a PR itself (seeapp/src/runner.ts). Since a past task that is where the session ends: the runner moves the task toawaiting_ciand returns, and the post-PR pipeline poller below does the rest. Nothing about CI, merging or deploying holds an agent, a lease or a concurrency slot any more. A task the pipeline hands back comes through this same path as an ordinaryqueuedtask, with the failed stage and its evidence prepended to the prompt - see the poller's failure table below. While a session is live, pinboard's "Running workers" screen (I4c) can request it stop:stopsends SIGINT and gives the session up to 30s to exit on its own - the runner still tries to parse a real handoff out of that exit, in case it managed to wrap up cleanly - before escalating to SIGTERM/SIGKILL;killsends SIGKILL immediately. Either way the task ends up back inreworkfor a human, since the operator explicitly asked the run to stop rather than for another attempt to start. foreman only reaches out to pinboard, never the reverse, so this is a poll (every 5s, alongside the existing lease renewal) of acontrolActionfield on the task, not a push. -
The dispatcher (
work dispatch, I7) removes the last manual step: it polls pinboard every 60s (--interval-secondsto change) and starts whatever the plan says may run.As of a past task foreman does not decide that for itself. pinboard's
GET /plan/nextreturns the task(s) that may start, already gated on the $ period budget, the open 5h session window and the concurrency limits (see pinboard's README, "the work plan"). The ranking and eligibility rules that used to live here - priority order,executorMode/autonomyLevel, thedepends_oncheck - moved there with it, so the Plan screen the operator reads and the dispatcher that acts cannot drift into disagreeing, and the copy that is visible is the one that decides.What this loop still decides for itself is only what pinboard cannot know: which of the offered tasks are already in flight here. A task's status flips to
executingwhen its claim lands insiderunTask, so between starting one and that claim landing pinboard would still offer it and would not count it against concurrency - henceselectStartable, which filters on what is in flight and caps at the plan's own concurrency figure.When the plan reports that the session window's budget is spent but the window has not expired, it hands back a
waitUntiland the loop sleeps until then instead of re-earning the same refusal every 60s (computeSleepMs, floored at the normal interval and capped at 5 minutes). The cap is short on purpose: the window is pinboard's estimate, and the operator can reset it from the Plan screen when it is wrong, which the loop then notices within 5 minutes. Re-polling only asks pinboard, never Claude. A refusal is logged only when it changes - "nothing runnable in the queue" is this system's normal resting state, not an incident worth a line every minute.--overrideisn't offered here: an unattended loop never overspends the budget on its own, and it never ignores adepends_oneither - only a human runningwork task run <id> --overridefor one specific task can do either (see "Override" below). SIGINT/SIGTERM stop the loop between cycles without cutting off a task that's already running.work plan statusprints what the dispatcher is about to see - budget left, the open session window, and which task(s) may start or why none may. foreman has no UI of its own, so this is how the plan is read from the box that acts on it. -
Override.
work task run <id> --overrideis the operator's explicit "run it anyway". It skips the budget gates (the period budget and the session window) and, on this direct path only, thedepends_ongate - before #140 this path never checked dependencies at all, so routing it through the plan would otherwise have silently removed that escape hatch. It never skips the concurrency limit: that stops the system running more paid agents at once than the operator allowed, whereas overspending is a decision the operator is entitled to make. -
Multi-license spawn (a past task, replacing #151's per-user sudo spawn). An agent belongs to exactly one pinboard
License(GET /licenses). Every run and chat turn executes as foreman's own Linux user (marek) with its own~/.claudeconfig; the license only decides which OAuth token theclaudespawn gets. A license'stokenEnvVarnames an env var (never the token itself) set in.envor synced into/app/secrets/global.env, which every foreman unit loads viaEnvironmentFile.app/src/spawn-env.tspasses that var's value to the child asCLAUDE_CODE_OAUTH_TOKEN, which overrides the stored login (a bad token fails with 401, no fallback).runner.ts,chat.ts,chat-daemon.ts/chat-session.tsall go through it, and the child inherits the rest ofprocess.envtoo, so the rest of global.env reaches every seat's agent. The one exception is the rawCLAUDE_OAUTH_TOKEN*vars, which are stripped so an agent only ever sees its own seat's token.License tokenEnvVar1 ( default, marek)CLAUDE_OAUTH_TOKEN2 ( peter)CLAUDE_OAUTH_TOKEN_PETER- No license,
tokenEnvVarnull, or the var missing or empty: the task run is refused and parked inreworkwith a comment saying what is missing (a chat turn errors the same way). It never falls back to a stored~/.claudelogin, which would bill the wrong subscription. Until 2026-09-29 a nulltokenEnvVardid fall back, and with both licenses left at null every seat's usage landed on marek's login. - Sessions all live in one
~/.claude, so a retained session is resumable by any license (isSessionResumableBy);claudeSessionLicenseIdis still recorded for accounting.
Adding a license:
- As the seat's owner, on any machine with Claude Code:
claude setup-token, and copy the token it prints. - Add it to foreman's environment as
CLAUDE_OAUTH_TOKEN_<NAME>: in.env, or in the secret store that renders/app/secrets/global.env(then sync it to foreman). - Recreate the services so they reload global.env (compose reads
env_filewhen a container is created, not on restart), each one while it is idle:docker compose up -d --no-deps --force-recreate dispatch chat pipeline. - Create the license in pinboard (or PATCH an existing one) with
tokenEnvVarset to that var's name:curl -X PATCH http://pinboard:8095/api/licenses/<id> -H 'content-type: application/json' -d '{"tokenEnvVar":"CLAUDE_OAUTH_TOKEN_<NAME>"}'.
Rotation: setup-token tokens last about a year. Rerun
claude setup-tokenas the owner, update the stored value, sync and restart the units as above. The license row doesn't change.Leftovers from the old sudo model, safe to delete by hand on foreman: the
/etc/sudoers.d/foreman-dispatchdrop-in (sudo rm /etc/sudoers.d/foreman-dispatch); aCLAUDE_BIN_BY_USERline in/app/.envif one was added there; and the two bridge edits in/home/peterthat got peter-runner working under sudo:env.PATHin peter's~/.claude/settings.json, and the npm token export before the interactive guard in peter's~/.bashrc. ThepeterLinux user itself is still needed for peter's own interactive sessions, not for foreman runs.License.linuxUseris no longer used for spawning.Also routing-affecting:
routing.ts'sresolveAgentForTasknow filters both rule-matched and fallback agents toagent.autonomyLevel >= task.autonomyLeveland returnsnull(not a degenerate synthetic agent) when nothing clears that floor -runTaskthen refuses the task outright with a clear error instead of claiming a lease for an agent that was always going to fail its own post-hoc autonomy check. The--agent=explicit path bypasses this filter by design (an operator naming an agent by hand is a deliberate override) and still relies on that same post-hoc check as its only guard, same as before this task.Model and effort. Every task run passes
--modeland--effortexplicitly, so nothing is inherited from the run user's~/.claude/settings.json. Model: the pinboard agent row'smodel, elseCLAUDE_MODEL, elseclaude-opus-5-5. Effort:CLAUDE_EFFORT, elsemedium. Chat threads use their owneffortwhen set and the same default otherwise. Both variables go inforeman-dispatch's environment. Opus 5.5 needs Claude Code 2.1.280 or newer for every run user.WORKSPACE_ROOTstays shared across every license for this increment - every agent, whichever license it runs under, still reads/writes the same~/projects/checkouts. - No license,
-
Dispatching per license. Having a second seat spawn correctly is not the same as having it start anything, and until this task it never did.
dispatchOnceaskedGET /plan/nextwith nolicenseId, which pinboard answers for its own default license, so every cycle read one seat's budget and session window - and any task that then routed to the other seat's agent was started on a budget check that had never looked at that seat at all. Two things fix it:- The loop polls once per enabled license (
GET /licenses, filtered toenabled), so each seat's own budget period, session window and refusal reason are evaluated on their own.work plan statusprints the same thing, a block per seat. When several seats have nothing to start, the loop sleeps until the earliest of theirwaitUntils, since sleeping past the first one to free up would idle it for no reason. That long sleep only happens when every seat is waiting: a seat with an empty queue, a full concurrency slot or a failed poll keeps the loop on the normal interval, so one seat at its 5h limit never holds up work the other seat could run. A seat in an infra cooldown counts as waiting until its cooldown ends. A pinboard with no licenses at all (older than #150) still gets a single unscoped poll, exactly as before. resolveAgentForTasktakes an optionalpreferLicenseId. A routing rule still wins whenever it matches - that is the operator's explicit intent - but an unrouted task now falls back to the asking seat's own runner rather than always to the globalforeman-runner. That fallback is restricted toexecutor: "cli"agents on that license, so a seat's LLM gateway executor (free local inference, deliberately reachable only through an explicit rule) and itscli-hostchat persona are never picked up as one. A seat with no eligiblecliagent starts nothing rather than borrowing another seat's. A task that resolves to no agent on any enabled seat is logged once per process (dispatch: task <id> has no eligible agent ...), so it doesn't sit inqueuedwithout a trace.
Because routing is what decides which seat pays,
dispatchOnceresolves it before starting anything (oneGET /tasks/:idper startable candidate, for the task's type and labels, which the plan candidate deliberately doesn't carry) and skips a candidate a rule sends to another seat's agent - that seat offers it on its own pass.runTaskthen asksGET /plan/gate/:taskIdfor the license the resolved agent actually belongs to, so the gate and the eventualusage_eventsrow always name the same seat.In-flight runs are tracked with the license they belong to, and
dispatchCapacity(which replaced #130'sselectStartable) allows a seat the global concurrency limit less everything in flight, further limited by that seat's own limit less what it already holds. Both figures come from/plan/next(concurrencyGlobalandconcurrency, the latter scoped to the asking seat since a past task); either can be the binding one, and a seat limit above the global never raises it. Counting foreman's own in-flight runs rather than pinboard's claimed ones matters because a task started here doesn't becomeexecutinguntil its claim lands insiderunTask. Ownership is resolved before that capacity is spent rather than after - capping the candidate list first would let the top of a shared, deterministically ordered queue (tasks routed to the other seat) eat a seat's whole window every cycle and starve it indefinitely. Each seat's poll is also independently try/caught, the same waypollPullRequestsisolates each PR, so one seat failing doesn't skip the seats behind it.The practical effect: both seats share one backlog and either can take the next unrouted task, whichever has budget and a free slot. Since a past task two tasks on the same repo no longer serialise either - each run has its own clone (see "Task workdirs" below).
Deploying. Runs as the compose
dispatchservice (restart: unless-stopped, so it survives a reboot with the docker daemon), next tochat(see below) andpipeline(see below). They are separate services so deploying one never disturbs the other two, andscripts/deploy.shkeeps it that way: afterdocker compose buildit brings them up one at a time withdocker compose up -d --no-deps <service>, and it skips a service that is in the middle of something and says so ("SKIPPED restarting ... keeps running the previously built code"). Recreating a container stops every process in it, so it would kill theclaudechild of a running task, a chat turn the operator is in the middle of, or a merge or deploy the pipeline is halfway through. A service is busy when itsworkprocess (the child of the container'sdocker-init) has a child of its own; re-runscripts/deploy.shonce it is idle. The decisions live inscripts/lib/runner-services.sh, tested byscripts/runner-services.test.mjs.foreman deploys itself. foreman's
.homelab/deploy.jsonis anlxcstep on hostforeman, so a merged foreman change makes thepipelineservice rundeploy-service.sh foreman, which SSHes back to this host and runsscripts/deploy.shthere. Recreatingpipelineat that point would kill the SSH client, and with it the rest of the deploy; and a pipeline killed while its task is still in thedeployingstage would deploy again on restart. So when the busy service's own process tree is runningdeploy-service.sh foreman, deploy.sh does not skip it but starts a detached (setsid nohup)scripts/deploy.sh --when-idle pipeline, returns success, and that waiter recreatespipelineonce it has been idle for two checks in a row (10s apart, up to an hour; log instate/logs/deploy-when-idle.log). The SSH hop needs foreman's own key in its ownauthorized_keys(marek's key, which the containers use per decision 4 in #514, already was). If deploy.sh is ever run inside a runner container (compose setsFOREMAN_SERVICEthere), it hands the whole deploy to the host over SSH (FOREMAN_HOST_SSH, default${HOMELAB_USER:-marek}@foreman), detached the same way, because the image holds no checkout to build from.The cutover (#518). The first run of this deploy.sh on a host still running the systemd units does the move itself, so merging #518 through the (then still systemd-run) pipeline cuts foreman over. It builds the image, runs
scripts/bootstrap-container-state.sh --migrate-workspace(moves~/projectsto/app/workspaceand leaves a symlink; a rename, so running processes keep working), then per service: an idle unit is stopped, disabled and deleted from~/.config/systemd/user, session transcripts are refreshed intostate/home, and the compose service starts; if compose cannot start it, the unit is put back. A busy unit keeps running and a detached--when-idlewaiter moves it once idle (the dispatcher running a task, the pipeline running this very deploy). A service is never on its unit and in compose at once. Until the move is finished,scripts/healthcheck.shaccepts a service still on its unit and says "cutover pending". Check it is done withsystemctl --user list-units 'foreman-*'(should list nothing) anddocker compose ps.chatneeds a different busy test from the other two, and getting this wrong was a real bug. The chat daemon holds aclaudechild per open thread, not per in-flight turn, so the child-process test called it busy whenever any thread was open at all: the restart was skipped, the daemon kept serving the previously built code, and the deploy still printed success - a deploy that looks clean and changes nothing, which is the kind that gets debugged twice before anyone suspects the deploy. It now asks pinboard instead, for the state pinboard already keeps:GET /chat/threads?status=open&awaitingAgent=true&taskId=none. A thread isawaitingAgentfrom the moment a human message is posted until the agent's reply lands, which covers a parked permission request and a parked question too;taskId=noneexcludes a task's own session thread, which that task's runner answers rather than this daemon. No such thread means restarting costs nothing - the daemon tears a session down when it goes idle anyway and resumes it from its storedclaudeSessionIdon the next message. An unreachable pinboard falls back to the child-process test, because without an answer there is no way to tell an idle thread from a running turn, and cutting a live turn is worse than skipping a restart.work task run <id>still works standalone as a manual override/escape hatch - the dispatcher doesn't replace it, only automates the common case. - The loop polls once per enabled license (
-
A retained session is scoped to the license that created it (task #174). A Claude session id only exists inside the
~/.claudeof the Linux user that created it, and #152's per-seat dispatcher made it routine for a requeued task to be offered to a different seat than the one that retained it - exactly what happened live on 2026-09-17, when marek's 5h window closed mid-run on tasks #166 and #169 and the requeued tasks were picked up bypeter's seat instead.peter-runnertried to resume a session id it had never heard of and failed three times in 25 seconds,is_error: No conversation found with session ID, burning the whole attempt cap on an identical, free, instant failure. Two fixes: pinboard'stasks.claudeSessionLicenseIdnow travels withclaudeSessionIdas one fact (setSessionStatewrites both,PATCH /tasks/:idclears the license whenever a caller clears the session id), andprepareAttemptcalls the newisSessionResumableBybefore ever considering a resume - a mismatch (including an unknown owner,null, which is treated as not resumable rather than gambled on) starts the attempt fresh instead. As a backstop for the case that check can't catch (a session store cleared or vacuumed within a single seat),session-limit.tsgained a third failure class,stale_session, for the same "No conversation found" message - the runner drops the stale id and retries cold without spending an attempt, the same free-retry treatment a past task already gave a session-limit failure. -
The planning chat poller (I8,
app/src/chat.ts) runs as its own process -work chat daemon, the composechatservice - polling every 2 seconds for chat threads withawaitingAgent: trueand replying viaclaude -p, in the scoped project's own repo checkout (or the workspace root if no project is scoped) so the agent can actually read code while discussing it. Tool access is deliberately narrower than the task runner's (agent-settings.ts'sCHAT_ALLOWED_TOOLS): read-only plusBash(work task create:*)only - this session never commits code, it proposes and, once agreed, files the outcome as a task for a later run to execute. Triesclaude -p --resume <session-id>first when a thread already has one stored; a resume that errors (a stale session id - Claude Code session files aren't kept forever) falls back to a fresh session with the prior messages replayed into the prompt for continuity, rather than surfacing that as an error to the operator.work chat pollruns one pass immediately and waits for the turn it starts, for testing a reply by hand.It shared the dispatcher's loop until a past task, which split it out after two distinct failures showed up live in chat thread #26. First, the chat poll was
awaited in front of the next dispatch cycle, so a turn parked on an unanswered permission request stopped task dispatch for as long as the operator took to answer - up to 30 minutes. Second, every deploy restartsforeman-dispatch, and systemd's SIGTERM reaches the whole control group, killing theclaudechild of whatever chat turn was mid-flight; the operator saw the turn's work vanish and acould not parse claude output as JSONmessage posted as the agent's reply. Two units, restarted independently, remove both.Turns are started rather than awaited, so two threads don't block each other, and a map of in-flight thread ids stops the 2-second poll from spawning a second session for a thread whose turn is still running -
awaitingAgentonly clears when the reply is posted, so without that guard every tick would start another one. That closes the overlapping- session race a past task recorded as a known gap here.- The host agent persona (a past task, keyed off
isHostsince #178/#179) is the one exception to the read-only persona above: a chat thread whoseagentIdpoints at a pinboardAgentrow withisHostset gets an unrestricted execution persona instead -claude -pruns with no--allowedTools/--disallowedToolsat all, i.e. full tool access equivalent to an interactive session, expected to actually edit files/run commands/commit rather than just discuss and file a task. A thread's ownmodel/effort(set at creation, a past task) still take priority over the agent's ownmodelover foreman'sDEFAULT_MODEL, for every persona - seechat.ts'sresolvePersona.isHostisn't set by this repo; an operator flips it on whicheverAgentrow they want this persona available for, via pinboard's Agents UI/API. It's per-agent rather than a single hardcoded agent name (the originalforeman-hostscheme) because pinboard'sagents.nameis globally unique, which meant only one license, ever, could own that name -isHostlets each license flag its own agent as its host persona.-
Auto permission mode with a hard always-ask floor (tasks #95, #145): the host persona has full tool access but never runs under
--permission-mode bypassPermissions. It runs under--permission-mode auto- the same mode an interactive Claude Code session uses, where Claude Code itself decides which calls are risky enough to stop and ask about - with two things wired underneath it:--permission-prompt-toolpointing atapp/src/permission-server.ts, a small hand-rolled MCP stdio server spawned per turn via--mcp-config(seechat.ts'shostPermissionMcpConfig). Anything that does escalate posts a message into that same chat thread describing the tool and its input, and blocks - polling pinboard, up to 30 minutes - for the operator to replyapprove,approve alwaysordenythere. Pinboard's chat view renders those as tap buttons. Denies by default if nothing arrives in that window.- A
PreToolUsehook,app/src/permission-gate.ts, registered through an inline--settingsJSON string (chat.ts'shostPermissionSettings). It matches a Bash call's command against a short list of genuinely destructive or outward-facing verbs -git push,git reset --hard,git clean,gh pr merge/close,gh release,gh repo delete/archive,rm -r/rm -f,sudo, service stop/restart,docker compose down/prune, the deploy scripts, anyssh/scp/rsync/pctto another machine, and any nestedsh -c/eval(whose real command is inside a quoted string where no pattern can see it) - and returnspermissionDecision: "ask"for them, forcing them through the approval surface above no matter what auto mode thought. A hook rather than--disallowedToolsbecause a denylist entry refuses outright with no way for the operator to say yes, which is not what "always ask me about this" means. The match steps over wrappers that don't change what a command does - inline env assignments (NODE_ENV=production git push),env/command/exec, and git's own global options (git -C dir push) - each of which was a real bypass caught in review before this shipped.
Until a past task this ran as
--permission-mode defaultplusHOST_ALLOWED_TOOLS, a hand-maintained prefix allowlist of pre-approved read-only commands. That inverted the problem: anything not literally on the list prompted, so chat thread #26 needed a separate approval for each ofcd,work --help,sed -n,systemctlandcurl, and theapprove alwaysreply keyed on a command's first three tokens so it never matched the next command either. Naming what must stop is both shorter and safer than trying to enumerate everything that may pass; the allowlist is gone.Per-turn caps are 45 minutes and $10 (
chat.ts'sDEFAULT_CHAT_OPTIONS). The timeout deliberately clearspermission-server.ts's own 30-minute approval wait - while it was 10 minutes, the outer kill always fired first, so an approval the operator answered slowly could never actually succeed. -
A stalled turn says so. Two things make a turn that is going nowhere visible instead of silent. First, the Claude CLI reports a failing API call it is about to retry as its own message type (
SDKAPIRetryMessage-type: "system", subtype: "api_retry");chat-session.ts'stranslateSdkMessageused to drop every message type it did not recognise, so those vanished. They are now posted into the thread as anerrorevent naming the reason, the HTTP status, the attempt and the backoff (API error - overloaded (HTTP 529), retry 3/10, next in 2.1s), and a first-byte timeout says how long it waited, since three silent minutes is the shape that reads as a freeze. The CLI's own.jsonlspells the same eventsubtype: "api_error"with camelCase fields, and both are accepted. The existingerrorkind is reused on purpose: pinboard and the PWA need no change, and since the payload has nointerruptedflag the PWA renders it inline and leaves the turn running - a retry is not a failed turn. Second,StallWatchdogpostsNo activity for Nmafter five quiet minutes and every five quiet minutes after that, for the windows where nothing arrives at all, not even a retry notice.stallNoticeMsoverrides the interval per session.Why it matters: on chat thread #40 (2026-09-18/19) the API stopped answering for most of two hours and the CLI retried correctly for all of it, but the operator saw an empty, unmoving transcript, assumed the agent was wedged, and stopped and restarted three turns - about $1.80 of abandoned work. Note what this does not do: there is still no hard per-turn time cap. The retry ladder is right, the 30-minute
IdleTimerstill catches a session that goes truly silent, and whether to wait out an outage or interrupt is now an informed operator decision rather than a guess. -
A question reaches the operator as option cards.
AskUserQuestiondid not work in a chat session at all: it was auto-allowed and then ran with nothing to answer it, so it returned "The user did not answer the questions". The agent noticed, retyped its questions as prose, and carried on - twice, for $0.84, on chat thread #32.canUseToolnow takes its own branch for the tool: it posts aquestionevent carrying the questions and their options, parks onPendingAnswers(the same wait a permission request uses, keyed by the SDK's own request id), and returns the operator's picks asupdatedInput.answers, which the CLI's own AskUserQuestion implementation reads back out of its input. The model keeps seeing the built-in tool with its built-in schema - nothing is aliased and no tool is re-implemented.Two properties are deliberate. The wait ends in an allow, not a deny: the tool then reports that nobody answered and the agent carries on, because a question the operator misses must never strand a turn. And a question with no answerable options is never posted at all - a card with nothing to tap would hold the session to its 30-minute deadline with no way to answer it. (The escape hatch if a question is posted and the operator does not want to answer it is the Stop button, which aborts the wait the same way.)
-
A turn ends when it is really over. Two emit bugs that #149's transcript made visible:
A
thinking_deltawith an emptythinkingstring is the API's redacted-thinking phase, where it streams only pings and the SDK digests the counters into its ownthinking_tokensmessage. Those blanks used to be posted asthinkingevents - 39 of thread #32's 82- rendering as nothing while still spending the per-thread
2000-event retention cap, which pushed real transcript out. They are
dropped, and
thinking_tokensbecomes one progress event every five seconds (THINKING_PROGRESS_INTERVAL_MS) carryingestimatedTokens, which the PWA renders as a single row counting up.
And a turn that launches an async subagent gets a
resultas soon as the main model's reply is done, while the subagent is still working. Thread #32's turn 2 "ended" at $0.11 and then streamed 40 more events before really finishing at $0.40 - so the working indicator stopped early, the per-turn cost shown was the figure as of the subagent starting, andpostUsagecharged the turn twice. The SDK'sbackground_tasks_changedmessage reports the live set of background tasks (a level signal: replace the set on each payload rather than pairing start/stop edges), and a result arriving while that set is non-empty is treated as interim - itscosttick still lands, flaggedinterim, but noturn_enddoes, and the result is held. It is released by the later real result, bysession_state_changed: idle(which the SDK documents as the authoritative turn-over signal), by the operator's next message, by the session closing, or - as a backstop for a CLI that reports background tasks but never idle - by a 60-second drain fuse armed once the background set empties and re-armed by any further message. So a turn can legitimately sit "running" for up to a minute after the last visible activity before the fuse closes it.A turn is closed out exactly once (
endTurn). The fuse and the SDK's own final result are on different call stacks, so without that guard a result landing just after the fuse fired would post a second agent reply and charge the turn's cost twice; a late result's cost tick is still recorded, since it is the more accurate figure, but it does not end the turn again. - rendering as nothing while still spending the per-thread
2000-event retention cap, which pushed real transcript out. They are
dropped, and
-
Chat spend is charged to the budget: each turn's
total_cost_usd/duration_msis posted to pinboard'sPOST /chat/threads/:id/usage, which adds it to the thread's running total and appends achat-source row tousage_events- the same 5h session window a task run is charged against. That route had existed since a past task but nothing here ever called it, so until this landed every chat thread readtotalCostUsd: "0"and the work planner silently under-counted every conversation. A failed resume that is retried fresh is charged for both attempts, because both cost money. A turn that dies without producing a result JSON at all (hard timeout, killed process) still carries no figure to report and goes uncharged; closing that needs the streamed events, which is task #148.
-
- The host agent persona (a past task, keyed off
-
The post-PR pipeline poller (a past task,
app/src/pipeline.ts) runs as its own process -work pipeline watch, the composepipelineservice - pollingGET /pipeline/nextevery 30 seconds and driving each task it finds through the stages pinboard added inStage What the poller does awaiting_cigh run list --repo <org>/<repo>for the PR's branch, per repo, where<org>is read from that PR's own URL (github.com/<org>/<repo>/pull/n), since a task's PRs can live in another org than its project; only a URL that does not parse falls back to the first path segment of the task's projectlocalPath, then the directory name ofWORK_HOMELAB_ROOT. A failed run's evidence is--log-failed, then each non-passing job's API log, then the run URL with every non-passing job's conclusion - never an empty block. A run still going afterFOREMAN_CI_TIMEOUT_MINUTES(default 30, so a cold build on a slow runner fits) is handled like a failed run. Only a run for the branch's current head commit counts. With no run for it yet, the PR'smergeablestate and the head commit's workflows decide: a PR that conflicts with its base (GitHub runs nopull_requestworkflow for it) is never a pass, so foreman rebases the branch onto its base and force-pushes within the 5-minute merge window, then hands the conflict back like a failed merge (merge_conflict, naming the conflicting files when git reported them);UNKNOWNmergeability waits for GitHub to finish computing it; a head commit with any.github/workflows/*.yml/*.yamltriggered bypushorpull_request(or workflows that could not be read) waits, and parks inreworkwith "no CI run appeared for " after the timeout. Only a repo with no such workflow proceeds once 60 seconds pass without a run. Green and the project hasautoDeployAllowed->awaiting_merge; green but the diff touches a drizzle migration (or its diff could not be read at all) ->awaiting_review; green with no auto-deploy opt-in, or aproductionrepo ->review, PR left open for a human.awaiting_reviewNothing - this is a human hold. Approving it in pinboard's review queue moves it to awaiting_merge. A task inreview(green CI, no auto-deploy) approved there takes the same route.awaiting_mergegh pr merge --squash --delete-branch, per repo, marking each PR merged in pinboard as it goes. Thendeployingwhatever the project'sautoDeployAllowedsays, because a merge reached from a review approval has that approval as its consent; orreviewif no repo involved has an LXC to deploy to (a library repo - see that repo's AGENTS.md "Close-out").deployinghomelab/scripts/deploy-service.sh <repo>under that repo's deploy lock, thenverifying. Targets come from the repo's deploy declaration (.homelab/deploy.json, orhomelab/targets/<repo>.jsonfor a repo outside the workspace's own org; see homelab/AGENTS.md "Deploy declarations"). An empty step list goes straight toreviewas "nothing to deploy"; a missing or invalid one parks the task inrework. Repos with annpmstep (libraries, today homelab-kit) deploy first so the version their consumers need is published before those consumers build; the rest keep their order. Secret store before its consumers: a repo whose workspace clone has akeyring.jsonat its root is the secret store. When a task merges a PR there, that repo deploys first, then every host whose rendered variable set changed in that PR'skeyring.json(diffed across its squash commit: a host that gained or lost a group, a group that gained or lost a name, a new host) is synced through the store's agent API (<repo>/scripts/agent-api.sh POST /api/sync, which needsKEYRING_AGENT_TOKENin foreman's.env), and only then does any other repo in the task deploy. So a secret-store PR and its consumer's PR can land in one task with no handoff note about ordering. A failed sync, or a diff that cannot be read, parks the task inreworkwith the failure as repair context and deploys nothing else.deploy-service.shitself does not sync: a human who merges akeyring.jsonchange by hand syncs the affected hosts before deploying them, same as before. The workspace clones involved (the repo andhomelab) are fast-forwarded first when on their default branch and clean, since nothing else pulls them.verifyingThe repo's own scripts/healthcheck.shover SSH, independently of the onedeploy-service.shalready ran, retried once per poll for up to 5 minutes. Then anexecutorMode=agenttask goes straight todone("Closed automatically (executor agent)", a past task), and the dispatcher's workdir sweep removes its workdir like any other finished task;human+agentand anything else goes toreview.Every 10 minutes the same loop also syncs pinboard's PR rows with GitHub (
pull-requests.ts, moved here from the dispatch loop in a past task): each row stillopenis read withgh pr view --json stateand patched tomerged/closed, and adonetask whose PR GitHub still has open gets a singleDone but PR still open: <url>comment. Each pass logs onepr-sync: checked N open PR(s), ...line.This is the work that used to run inside
runTaskasmergeAndDeploy, where it held the task's Claude session, its lease and the single agent concurrency slot for the ten-plus minutes CI plus a deploy takes - none of which it needs. Splitting it out is what frees the agent slot at the PR (a past task).The poller keeps no state of its own: every decision comes from
/pipeline/next's payload (includingstageEnteredAt, which is the clock for every timeout, so restarting this process does not give a wedged stage a fresh budget), and every move goes back throughPOST /tasks/:id/pipeline, which refuses anything the state machine does not allow. Killing and restarting it mid-pipeline loses nothing.A task that has PRs in more than one repo (a past task's cross-repo support) is driven across all of them: the repo set comes from the task's own PR list filtered to this attempt's branch, and a stage only advances once every repo has cleared it.
Releases probe and Deploy now (a past task,
app/src/releases.ts) ride on the same loop, since they need the same SSH andghaccess and the same per-repo deploy lock. After each pipeline pass it claims at most one Deploy-now request from pinboard (POST /releases/requests/claim), runsdeploy-service.sh <repo>withDEPLOY_ACTOR=agent:foreman-releasesandDEPLOY_REQUEST_ID, waiting up to 10 minutes for a busy lock, then reports the output and re-probes that project. Every--probe-interval-seconds(default 1800; a full pass takes about 2 minutes and holds up pipeline stages meanwhile) it probes every project: reads each declared step's checkout sha over SSH (/app, or/app/projects/<app>for anappstep), the default branch's sha fromgh api repos/<org>/<repo>/commits/HEAD, and the commits in between from GitHub's compare API, thenPUT /releases/targets/:projectId. A repo whose steps name theproductionhost is never deployed from Deploy now.work releases probeandwork releases deploy-nextrun either step by hand.Production releases (a past task,
runReleaseinapp/src/releases.ts) come through the same claim, as a request withkind: release. They run only from that human click; the declaration'sreleaseblock is parsed bydeployStepsForbut is never a step, so the post-merge pipeline cannot reach it. In a scratch dir,/app/tasks/.releases/request-<id>(removed on success, kept on failure for inspection), it:- clones the app repo, checks the release branch is an ancestor of the
default branch, bumps every
versionFilesentry (they must agree) by the requested bump, refuses if tagv<version>already exists, commitsUpdate: release v<version> (pinboard release request #<id>)and pushes it to the default branch; - fast-forwards the release branch to that commit;
- polls
gh run list --workflow <workflow> --branch <branch>every 15s, up to 30 minutes, for the run on that commit, and needs it to succeed; - clones
<composeRepo>(from the org of its workspace clone), rewrites every declared image's pinned tag incomposeFile, commits and pushes; - runs
deploy-service.sh --release <repo>under the repo's deploy lock, withDEPLOY_SHAset to the bump commit.
Nothing here is atomic. Each failure message says what was already pushed: a bump that reached the default branch stays there (the next release bumps past it), and after step 4 the new tag is pinned but may not be running (
deploy-service.sh --release <repo>by hand finishes it). The probe reports the release target from the tag the host's compose file pins, resolved to a commit withgh api repos/<org>/<repo>/commits/<tag>. The pipeline loop is blocked while a release runs, mostly on the image build (a few minutes).A failed stage either hands back to an agent or parks for a human . The dividing line is whether another agent run would have anything to act on:
Failure What happens CI failed Handback, ci_failed. The evidence isFailing steps: <job> / <step>plus the failing step's own log:gh run view --log-failed, or, when that prints nothing (reusable-workflow runs do), each failed job's raw log viagh api .../actions/jobs/<id>/logscut from the failing step's##[group]Runheader to its##[error]line. If neither yields anything the comment says so and links the run. The PR stays open and takes the fix as more commits on the same branch.CI failed, same run as last handback Park in rework. Eachci_failedhandback comment ends with aFailed CI run: <repo> <run id> (head <sha>)marker; if the next CI check finds that same run id, the agent pushed nothing new and another run would read the same failure.CI failed, default branch already red on the same step Park in reworkwith "main of is already failing (); not caused by this task". Checked against the latest completed run on the repo's default branch, matching bare step names. No attempt is spent. A red main on a different step still hands back.gh pr mergerefusedThe poller first rebases the task's own clone onto origin/mainand force-pushes it, then retries the merge on the next poll - the usual cause is the branch having fallen behind while it sat in CI, and a rebase costs nothing where an agent run does not. Only if the rebase itself fails, finds the branch already current with main (so staleness was not the problem), or the merge is still failing 5 minutes in, does it hand back asmerge_conflictwith git's own output. A rebase that would change nothing is never pushed, so a merge failing for some other reason cannot re-trigger CI on every poll.Deploy script failed Handback, deploy_failed, with the script's output. The change is already on main at this point, so the repair run is told to fix forward rather than re-land it.Deploy hit Docker Hub's pull rate limit Not a handback: the output matching toomanyrequests,429 Too Many RequestsorYou have reached your ... pull rate limit(isDockerHubRateLimited) leaves the task indeployingand retries the deploy after 30, 60, then 120 minutes (DEPLOY_RATE_LIMIT_BACKOFF_MS). Each retry posts a comment ending inDocker Hub rate limit: retry <n> of 3, next deploy attempt after <time>; the poller reads those back, so the schedule survives a restart. Once the backoff is due the 30-minute lock-wait bound restarts from that moment. Still rate-limited on the fourth attempt, it hands back asdeploy_failedlike any other deploy failure.Healthcheck never passed Retried once per poll for 5 minutes (a just-recreated service can legitimately fail its own check for a few seconds), then handback, deploy_failed.No PR recorded, no branch recorded, CI unreadable for the whole timeout, deploy lock never freed Park in rework. None of these is something an agent run in a repo checkout can fix.A handback is
POST /tasks/:id/pipeline {to: "queued", repairReason, repairContext}. Pinboard refuses a handback that carries no reason, and deliberately does not resetattemptCount(a plain human requeue does) - so the runner's own attempt cap is what ends a repair loop. A task whose CI keeps failing gets its remaining attempts and then parks inreworklike any other, rather than being handed back forever.Only an autonomous task is handed back. A "handback" in the table above applies to an
executorMode: agenttask. Ahuman+agenttask has a person on it, and a second agent started behind them would race whatever they are doing, so the same failure parks it inreworkinstead (routeHandbackinapp/src/pipeline.ts), with the samerepairReason/repairContextattached and the same failure notification. When the human requeues it fromrework, pinboard keeps those fields, so the run they start reads the failure exactly as an autonomous repair would. A pinboard too old to sendexecutorModegets the park, never the handback.The resumed run is an ordinary dispatch of a
queuedtask: it resumes that task's own Claude session in its own clone, on its own branch, rebased onto main byprepareAttemptas any resume is. What makes it a repair isbuildPrompt, which prepends the reason and the evidence (repairPromptLines) to the task's normal prompt - including which of the three situations the change is in, since that decides where the fix goes: a red PR is still open and takes more commits, a merged-then- failed deploy needs a new change on top of main.work pipeline pollruns a single pass and prints what moved, for driving a stage by hand. It is safe to run while the service is up - every decision is re-derived from pinboard and every transition is checked there. - clones the app repo, checks the release branch is an ancestor of the
default branch, bumps every
-
Mid-task clarifying questions (I9,
app/src/ask.ts) let the task runner's own session (not the planning-chat poller above - this is the privileged session actually doing the work) pause and ask the operator something instead of guessing.work task ask <id> "<question>"is a Bash tool the running session calls itself (seeagent-settings.ts'sDEFAULT_ALLOWED_TOOLS): it posts the question to a chat thread scoped to that task (reusing I8'schat_threadstable,taskIdset instead ofprojectId) and blocks up to--wait-seconds(default 90, deliberately short and safely re-callable rather than risking Claude Code's own Bash tool timeout killing a longer single wait) for a reply, printing it once it arrives. No reply in time prints a message telling the session to call again to keep waiting or proceed with its own judgment - a retry with the same question text finds its own pending question (or an answer that arrived after a prior call's wait window already gave up) rather than posting a duplicate; a genuinely different question always posts fresh. The task runner's hardtimeoutMspauses while a question is outstanding (runner.ts'sadvanceTimeoutTracker/hasTimedOut), so an overnight dispatch run waiting on the operator doesn't burn its whole timeout budget doing nothing.A past task narrowed when a run asks. The runner prompt's autonomy policy (
runner.ts'sautonomyPolicy) has it decide reversible, in-scope choices itself and record them withwork task assume <id> "<decision>" --why="<text>", which stores a pinboard ask of kindassumptionand returns at once; the operator can accept or veto it later.work task askis kept for irreversible/destructive, production-impacting, secrets, scope-change, money, or task-contradicting decisions, and takes--context=, 2-4--option="<label>|<rationale>"and--recommended=<n>(1-based). Each ask is also posted to pinboard'sPOST /tasks/:id/askswith the chat thread's id, so answering it in the asks view posts the answer into that thread and the waiting call picks it up; a plainwork task ask <id> "<question>"is stored as a free-text question with no options. The next attempt's prompt lists every answered ask and assumption (askHistoryLines), so a run resumed afterneeds_inputstarts from the answers. The handoff has asummary(a TL;DR of at most 5 lines plus a "Needs you:" line when something is left for a human), ahowToVerify, and an optionalchecklist: one short, testable line per thing a human has to check by hand (play, feel, look, browser), with nothing the automated tests already prove, empty when there is nothing to check by hand. The checklist is sent to pinboard with the handoff;work task handoff --summary= --checklist=<item>...sets them by hand. The PR body (formatPrBody) leads with the summary, thenCloses pinboard task #id, a short## Changed(the files the branch touches) and## How to verify, with the full Completed/Changed/Verified/Problems/Discovered collapsed under "Agent detail". Pinboardwork task answer <askId> <option|text...>answers an open ask (POST /asks/:id/answer, requeueing aneeds_inputtask); a leading number is the 1-based option. It is on the planning chat allowlist, so a thread seeded by pinboard's "Resolve in chat" can settle its asks. -
Failure classification (a past task,
app/src/session-limit.ts) decides whether a failed attempt should cost an attempt at all. The loop used to treat every non-terminal failure as "try again", which is right for a flaky test and wrong for the two failures that actually happen:- Session limit. Claude prints
You've hit your session limit - resets <time> (UTC). The reset time is parsed (a bare UTC clock time, resolved to its next occurrence), posted to pinboard'sPOST /plan/session-limit, and the task goes back toqueuedwith its attempt refunded - not torework. Nothing a retry does clears a 5h window; only waiting does. The requeue and the attempt count are sent in one PATCH on purpose, because pinboard resetsattemptCountto 0 for any status change toqueuedthat doesn't name the count itself. - Budget exhausted. A run whose cost reached its
--max-budget-usdcap stops immediately and goes toreworkwith "ran out of budget at $X". Retrying is pointless: every attempt gets a fresh cap, so a task needing more than the cap just buys the same wall again at full price. - Infrastructure (a past task,
app/src/infra.ts): workdir preparation failing withNo space left on device/ENOSPC, the CLI-version error (does not support this model; version ... or newer is required. Run 'claude update'), or a claude auth/login error. These never spend an attempt: the task goes back toqueuedwith the attempt refunded, gets one comment naming the cause, and the dispatcher stops claiming for that seat (a full disk: for every seat) forFOREMAN_INFRA_COOLDOWN_MS(default 30 min). The cooldown is in memory, so restartingforeman-dispatchafter fixing the cause clears it. An outdated CLI first gets oneclaude update(every seat shares foreman's own install since a past task); if the version changed, the task retries at once. - Everything else retries as before, up to
--attempts.
Disk preflight: before claiming anything, each poll checks free space on the filesystem holding
/app/tasks. BelowFOREMAN_MIN_FREE_GB(default 3) it runs the workdir sweep immediately; if still below, it claims nothing, logsdispatch: preflight refused ...and cools down. It logsdispatch: preflight ok ...once after start and again whenever the state changes. Nothing is claimed, so no task gets a comment.Operator alerts (a past task,
app/src/health.ts): the conditions above that only a person can fix also reach pinboard's notification bell, viaPOST /api/health/report(actorsystem:foreman). Checks:8ball(an 8ball-executor run could not get an answer, filed under the pinboard projectEIGHT_BALL_PROJECTnames, none if unset),seat:<licenseId>(auth or outdated CLI thatclaude updatedid not fix) anddisk(preflight refused after a sweep), both projectforeman. Only transitions are sent: one "is failing" entry, then one "recovered" entry on the next good gateway run, next seat run that is not an infra failure, or next passing preflight. State is in-memory, so after a restart a still- failing check reports once more. A report pinboard does not accept is logged (health: could not report ...) and retried on the next observation. To test: run an 8ball-executor task with a badEIGHT_BALL_URL, and the bell showsforeman: 8ball is failing.Pinboard watchdog (
app/src/pinboard-watchdog.ts): the alerts above go through pinboard, so they say nothing when pinboard itself is down. The dispatch process runs a second, independent loop that probes${PINBOARD_URL}/healthand${PINBOARD_URL}/api/planeveryPINBOARD_WATCHDOG_INTERVAL_SECONDS(30); either not answering 2xx counts as failing. AfterPINBOARD_WATCHDOG_OUTAGE_MINUTES(10) of failures with no good probe in between it declares an outage: one ntfy push (POST $NTFY_URL/<topic>, bearer token, topicpinboardby default) and, if the bounds allow, one call of the repair hook. Bounds: one run per outage, at mostPINBOARD_WATCHDOG_MAX_RUNS_PER_DAY(2) in a rolling 24h. The hook does nothing yet; the log showspinboard-watchdog: repair would start. When pinboard answers again it pushespinboard is back after N minand appends a record to the state file'spendinglist (start, end, minutes, whether a run started, last error), for a later step to turn into a pinboard task. State (failure start, outage flags, run timestamps, pending records) is in~/.foreman/pinboard-watchdog.json, so a restart keeps an outage in progress and the daily run count. A gap of more than 5 intervals between probes (foreman itself was down) restarts the failure timer. WithoutNTFY_URLand the token, each push is only logged. While healthy it logs one line an hour (N healthy probe(s) in the last hour).The dispatcher needs no separate mechanism for any of this: the reported reset reaches it as the
waitUntilthatGET /plan/nextalready returns andcomputeSleepMsalready sleeps on.The concrete history, from tasks #117 and #115 on 2026-09-17: #117's attempts 2 and 3 cost $0 each, ~15s apart, against a session limit, and the attempt cap then sent a perfectly good task to rework; #115 repeated it unaided minutes later. Separately #117 spent $2.0203 and then $2.0379 against a $2 cap - $6 to prove the cap was too small.
- Session limit. Claude prints
-
Per-run budget comes from pinboard's gate, not from a constant here.
GET /plan/gate/:taskIdreturnsbudgetUsd, sized from that task's own cost estimate (2x the estimate, floored at $2, capped at $10). An explicit--budget-usdfrom the operator still wins.DEFAULT_RUN_OPTIONS.budgetUsdsurvives only as the fallback for callers that never reach the gate.
A past task, an earlier story. A repo whose GitHub org (repoOrgFor, read from the
workspace clone's origin) is in PUBLIC_GITHUB_ORGS (comma-separated,
default rackmarten, set in docker-compose.yml; an empty value means no
repo is public) gets nothing foreman or its agents write to GitHub that
points back at private infrastructure. Decided per repo, so one task can
push to a private and a public repo at once.
- Agent prompt. When any repo in the run is public, the prompt adds a
"Public repository rules" block naming those repos: no
task #<n>or pinboard links, private service or host names, CTIDs/LAN addresses, private domains or private-repo links in commits, PR text, CHANGELOG, comments or docs there. Handoffs and pinboard comments stay private and detailed. - PR text. A public repo's PR title is the branch's first commit subject
(a private repo keeps
<task title> (pinboard #<id>)), and its body is the handoff summary, the changed files and how-to-verify, without the "Closes pinboard task" line or the collapsed agent detail. The squash merge passes its own--subject(the PR title without a(pinboard #<id>)suffix, plus(#<PR number>)), so a PR opened before its repo went public still merges clean. Foreman posts no other comments on GitHub. - Branch names (
chooseTaskBranchinapp/src/workdir.ts). A task whose repos include a public one gets<slug>, with no task id, e.g.add-player-launcher; any other task gets<id>-<slug>, e.g.832-add-player-launcher. The slug is the task title lowercased, accents folded to ASCII, anything else turned into word breaks, at most 5 words and 40 characters. An empty slug falls back totask-<id>(private) orchange(public). A public name that any public target's origin already has gets-2,-3, ... until it is free, so another task's branch (an open PR's head always exists on origin) is never reused. The name is decided once, on the first attempt: every later attempt, repair handback and second repo's PR reads it back from the task's PR rows in pinboard (branchName) or the branch the task's existing clone is on, never from the title again. One name covers all of a task's repos, so its private repos get the public shape too when one repo is public. Workdirs staytask-<id>; they are never pushed. - Pre-push leak gate (
app/src/leak-gate.ts). Before pushing a task branch to a public repo, foreman runs homelab'sscripts/leak-check.shfrom the workspace clone with--patterns public-leak-patterns.txt --log origin/<base>..HEAD --stdin, feeding it the added lines ofgit diff origin/<base>...HEAD, the PR title and body, and the branch name when this push creates the branch (one origin already has is public by then, so a repair push to an oldertask-<id>branch still goes through). Stdin hits are mapped back to<file>:<line>, the PR title/body orbranch name, and diff hits matching the repo's.leak-alloware dropped. Any remaining hit, exit 3 (no patterns), a missing script or pattern file, or a script error blocks the push: the task goes toreworkwith a comment listing pattern ids and locations, never the matched text. Private repos skip the gate entirely.
Check the resolved value with docker compose config | grep PUBLIC_GITHUB_ORGS
or docker compose exec dispatch printenv PUBLIC_GITHUB_ORGS.
Every agent run works in its own directory tree under /app/tasks, created
by the runner and removed by its sweep:
/app/tasks/task-<id>/repos/<repo> one local clone per repo the task may write to
/app/tasks/.locks/<repo>.deploy.lock a held deploy lock (see below)
- One clone per task, not a shared worktree. Before this, a
run got a
git worktreeinside the single workspace checkout of that repo (~/projects/<org>/<repo>/.claude/worktrees/), which is why pinboard refused to start two tasks whose repos overlapped: they would have been writing the same directory tree. A clone shares nothing, so that rule is gone and two tasks on one repo can now run at the same time, on either seat. - Why
/app/tasks. Every other service in this org keeps its state in/appon its own LXC, which is what makes an LXC rebuildable; foreman has drifted from that, and new state is placed the way the rest should eventually be (a past task tracks pulling the rest back in). The directory is gitignored, created byscripts/deploy.shas mode 2775 owned by groupmarek, because a second license's runs are spawned as their own Linux user and must be able to create their own task directories.WORK_TASK_ROOT(set in both systemd units) overrides the location. - Cloned from the workspace checkout, so no network fetch
(
git clone --local --no-hardlinks- hardlinking is impossible here, because the shared workspace clone holds objects owned by the other operator's Linux user andfs.protected_hardlinksforbids linking a read-only file you neither own nor can write), thenoriginis repointed at GitHub and the task's branch (see "Branch names" above) is cut fromorigin/main- not from the workspace checkout's ownmain, which can be days stale. - node_modules is copied, not installed, for each package directory whose
package-lock.jsonis byte-identical to the workspace checkout's. A real copy (cp -a), never a hardlink: a tool that rewrites a file in place would otherwise corrupt the workspace checkout's own dependencies. A mismatched lockfile just means the session runs its ownnpm ci. - The sandbox follows cwd. The session is spawned with its cwd at the
task's own clone of its project's repo and no
--worktreeflag; a related repo's clone is a sibling directory, so it is granted explicitly with--add-dir. Everything outside the task's workdir stays denied to Bash/Edit/Write exactly as before (the Read tool is exempt, as it always was). - A repo related mid-run. When the agent runs
work task add-related-repo <id> <project>inside its own session (the task has a workdir underTASK_ROOT), the CLI records the relation and also clones that repo intorepos/<repo>on the task branch, the sameensureTaskClonea pre-declared related repo gets, and prints the path. The session's--add-dirgrant covers the task's wholerepos/directory, so the new clone is writable without restarting. The CLI may run as the other seat's user, whose$HOMEis not the dispatcher's, so the runner writes its workspace root to<workdir>/workspace-rootfor the CLI to clone from. After the session the runner re-reads the task's related projects before pushing, so that repo gets its own PR like any other. Run on a workstation (no task workdir), the command only records the relation. The agent still has nogit clone: where and on which branch a repo is cloned stays the runner's decision. - Retention: a workdir survives until its task reaches done/rejected,
or is in
reviewwith every PR row merged (a past task, nothing left to resume there), or has sat untouched for three days, so a rework or a resumed session still finds its checkout and itsclaudesession id. Queued, in-progress, rework and pipeline-stage tasks only ever lose theirs to the three-day rule. The sweep is now one recursive delete instead of removing a worktree and its branch in each repo. - Deploy lock. Since two tasks can now merge to the same repo at once,
the one step that must not overlap is the deploy: the pipeline poller's
deployingstage takes/app/tasks/.locks/<repo>.deploy.lockbefore runningdeploy-service.sh, and takes over a lock whose holder is dead or older than 30 minutes. It is a file, not an in-process mutex, because a manualwork task runis a different process from the poller. Since a past task a task that cannot get the lock waits across polls instead of blocking on it (5 seconds per attempt, so other tasks' stages keep moving), and only gives up - saying so on the task, leaving the deploy to a human - once it has been indeployingfor 30 minutes.
Known limitation: the sweep prunes the stale ~/.claude.json trust entries
only in the dispatcher's own home. A second license's runs are spawned as
their own Linux user and write trust entries into that user's ~/.claude.json
instead, which marek cannot read (/home/peter is 0770), so that file grows by
one entry per task per repo until it is tidied by hand from that account.
Worktrees created before a past task are no longer touched by anything; run
scripts/sweep-orphaned-worktrees.sh --apply once to clear the ones whose
tasks are finished.
A task can build on another task's work before that work is merged. When
pinboard's task payload carries stackOn (the parent task and its open PRs,
one per repo), each repo the parent has an open PR in is stacked:
- Cut from the parent's branch. A new branch is cut from
origin/<parent branch>instead of the default branch, so the parent's unmerged changes are already in the checkout. A branch that already exists on the remote still wins, as for any other task. If the parent's branch is gone (merged and deleted meanwhile), the clone is cut from the default branch like an unstacked one. - Recorded in the clone. What the clone stacked on (parent task id,
branch, PR URL and the parent's tip at cut time) is kept in the clone's
own git config (
foreman.stack*), so the steps below read the same answer. For a branch that already existed, the recorded sha is where it forked off the parent. - Base everywhere is the parent branch while it still exists on the
remote: the pre-push rebase (
rebaseOntoMain), the pipeline's merge-conflict rebase, the "has commits" check, the PR's title, file list and leak-check range, andgh pr create --base. Once it is gone they fall back to the default branch. - The PR says so. Its body opens with "Stacked on , merge that
first.", and the PR row sent to pinboard carries
baseBranch,stackedOnTaskIdandstackedOnSha(an older pinboard ignores them). - The agent is told. The prompt names the parent task and its PR, and says to build on that work rather than redo it.
A stacked PR's row also records the parent commit it sits on
(stackedOnSha). Whenever that parent moves, the child is restacked: only
its own commits (those after the recorded sha) are replayed onto the new
base, so its diff never picks up the parent's work twice. This lives in
app/src/restack.ts.
- Parent merged. After the pipeline squash-merges a PR
(
gh pr merge --squash --delete-branch), it restacks every open PR stacked on it straight away: it checks GitHub retargeted the child to the default branch (gh pr edit --baseif not), runsgit rebase --onto origin/<default> <recorded parent sha>, pushes with--force-with-leaseagainst the head it rebased, and records the default branch and its head as the PR's new base. From then on it is an ordinary PR. A parent merged by hand on GitHub is picked up by the next PR sync. - Parent moved. The PR sync (every 10 minutes, in the pipeline service)
compares each stacked PR's recorded sha with its parent branch's head. A
new head (an agent rework, a commit pushed from an interactive session)
restacks the child with
git rebase --onto origin/<parent> <old sha>and records the new sha. A child already on top of the new head (a resumed run rebased it itself) only gets its record updated. - Chains (A <- B <- C) are handled parents first in one pass: restacking B gives it a new head, which C then sees as a moved parent.
- Conflicts. A rebase that conflicts is aborted before anything is
pushed, so the remote branch is untouched, and the child moves to
reworkwith a comment naming the parent commit it could not rebase onto. A child whose recorded parent commit is no longer in its branch (the parent was force-pushed, or the child rebased by hand) goes the same way, since no later pass would get any further. - Which children. Only a child in
review,awaiting_review,awaiting_ciorawaiting_mergeis restacked. A running session owns its branch (and rebases onto its base before pushing anyway), and a child inreworkor waiting on a question is left to whoever has it. The rebase runs in the child's own task clone when it is still on disk (itsforeman.stack*record is updated, or cleared once the parent merged), otherwise in a throwaway clone of the workspace checkout. A rejected parent is pinboard's to handle: it sends the children back torework. - Merge guard. The pipeline never merges a PR whose base on GitHub is not
the repo's default branch. pinboard's approve guard already refuses to
approve a child before its parent merged; if one reaches
awaiting_mergeanyway, it parks inreworkwith a comment instead of landing the parent's unreviewed work through the child. It also reads pinboard's recorded base, because GitHub retargets a child the moment its parent's branch is deleted: a child whose restack has not landed (it still carries the parent's pre-squash commits) is parked too.
work playtest --project=<slug|id> [--push] [--dry-run] gives the operator
one local branch with everything in review merged in, to build and try it
all at once. Run it inside a clone of that project's repo: it uses git in the
current directory and nothing else about where the repo lives, so it works
the same from a Windows clone through the work shim.
- It reads the project's tasks in
reviewfrom pinboard and takes each one's newest open PR in this repo (pinboard'srepoSlug, the basename of the project'slocalPath). A task without one is listed as skipped. - It runs
git fetch --prune origin, createsplaytest/<yyyymmdd>-<n>(the nextnnot taken locally or on origin) fromorigin/<default>and checks it out. - Each PR's head branch is merged with
--no-ff. A stacked PR's head already contains its parent, so only a stack's leaves are merged: stacks first, then independent PRs, each by task id. A merge that conflicts is aborted and skipped; for a stacked leaf, its parent is tried next. - The summary lists every included task (
#id title, PR URL) with the checklist from its newest handoff that has one, then the skipped tasks and why.
It refuses to run with uncommitted changes. --dry-run prints the merge
plan and the summary without fetching, creating a branch or merging.
--push pushes the branch to origin; by default it stays local. Delete old
playtest branches by hand (git branch -D playtest/...).
A past task, after the disk hit 98% of 32G on 2026-09-28 (docker images,
build cache and anonymous volumes from agent test runs, plus the npm cache).
Since a past task it is the compose housekeeping service (profile
housekeeping, so up never starts it), run nightly at 04:30 by a host
crontab entry that scripts/install-housekeeping-cron.sh writes (deploy.sh
runs it every time; HOUSEKEEPING_SCHEDULE overrides the schedule). It
runs scripts/housekeeping.sh --apply, which:
- prints
dffor/anddocker system dfbefore and after; - prunes stopped containers older than 24h, unused images and build cache older than 72h, and anonymous volumes (named volumes are never touched). This is the HOST's docker daemon, through the mounted socket, which is the one agent runs fill up;
- runs
npm cache verify; - reports the image's
claude --versionagainst the newest published@anthropic-ai/claude-code, and prints aWARNINGwhen the image is behind.claude updateno longer applies: the CLI comes from the image, so the fix is bumpingCLAUDE_CODE_VERSIONin the Dockerfile and deploying. An outdated CLI is an infra failure (#440), so it should not go unnoticed.
Without --apply the script is a dry run that only prints the commands and
current versions. Each step's failure is logged and the rest still run.
docker compose --profile housekeeping run --rm housekeeping # run once now
tail -n 50 state/logs/housekeeping.log # the nightly runs
crontab -l # the schedule
Task workdirs are not part of this; the dispatcher's own sweep handles those (see "Task workdirs").
The runner moved from the systemd --user units into Docker Compose in
three steps: the image and compose file, the state layout, then the
cutover. Deploy with scripts/deploy.sh rather than a bare
docker compose up, which would recreate a busy service. One-off
docker compose run --rm dispatch <command> is always safe.
Dockerfile:node:22-bookworm-slim(pinned by digest) plus git, gh, openssh-client, curl, python3, procps, sudo and the docker CLI with the compose and buildx plugins (no daemon: the host's socket is mounted). Claude Code is installed globally at a pinned version (CLAUDE_CODE_VERSIONbuild arg, auto-updater off), so updating it means bumping that arg and rebuilding, notclaude update.app/is built in the image andworkis a symlink on PATH, so there is nonpm link. Usermarek(uid/gid 1000) is in adockergroup whose gid is theDOCKER_GIDbuild arg (991 on foreman) and has passwordless sudo inside the container (decision 5 in #514).docker-compose.yml:dispatch,chatandpipeline, plushousekeepingbehind thehousekeepingprofile. Stop grace periods match the old units'TimeoutStopSec. Each service setsFOREMAN_SERVICEto its own name.env_fileis.envthensecrets/global.env, both optional. Mounts: the docker socket,${WORK_TASK_ROOT}at the identical path,./workspace(holding<org>/<repo>, what pinboard'slocalPathis relative to),./stateand./secretsread-only. No published ports..env.examplelists every variable the containers read.
Build and smoke-test (every dependency comes from the public npm registry, so the build needs no token; the image is about 2GB):
docker compose build
docker compose run --rm dispatch work task list
docker compose run --rm -e CLAUDE_CODE_OAUTH_TOKEN dispatch claude -p 'reply with the single word ok'
claude -p works in a fresh container with no ~/.claude at all and only
CLAUDE_CODE_OAUTH_TOKEN set: no onboarding, trust or settings file is
needed, including with the runner's --permission-mode bypassPermissions
and through the agent SDK the chat daemon uses. What a cold ~/.claude
does lack is behaviour, not function, and the state-consolidation child has
to seed it into the persistent HOME: settings.json (notably
attribution/includeCoAuthoredBy off, or runs add Claude trailers to
commits and PRs, plus permissions), CLAUDE.md, agents/ and skills/
(the shared config from homelab's claude-config/), projects/ (session
transcripts, needed to --resume a retained session across restarts),
plus ~/.ssh and ~/.gitconfig.
Every fetch and push to GitHub from foreman goes over https and
authenticates with GH_TOKEN; the SSH key is only for reaching
the LXCs. Remotes stay git@github.com:<org>/<repo>, git rewrites them:
- Containers: the image's
/etc/gitconfigsetsurl.https://github.com/.insteadOfforgit@github.com:andssh://git@github.com/, and the credential helpergh auth git-credential, which readsGH_TOKENfrom the env_file. - Host (the workspace clone the systemd
--usertimers fetch, e.g. the weekly version check):scripts/setup-host-git.sh, run by everydeploy.sh, puts the sameinsteadOfentries in the invoking user's~/.gitconfig, withscripts/git-credential-github.shas the helper. It readsGH_TOKENfrom the environment, elsesecrets/global.env, else.env(the same token the containers use, no second secret). With no token it gives no credential and the fetch fails; it never falls back to SSH or to aghlogin.
The token needs repo (push) on every org foreman works in, for
example both a private org and a public one, plus workflow for repos with Actions.
Check one repo with gh api repos/<org>/<repo> --jq .permissions.push.
All of the runner's mutable state lives under /app, so rebuilding foreman
means restoring these directories, not recreating a home directory from
memory:
| Path (host = container) | What | Back up? |
|---|---|---|
/app/state/home |
HOME for every container process: .claude/ (settings, CLAUDE.md, agents, skills, session transcripts in projects/), .claude.json (workspace trust), .ssh/ (key + known_hosts), .gitconfig |
Yes. Transcripts are what --resume needs; the rest can be re-seeded by the bootstrap script |
/app/workspace |
THE workspace clone, <org>/<repo> (decision 2); the operator's ~/projects is a symlink to it |
Optional: everything is on GitHub, but a re-clone takes a while and unpushed operator work lives here |
/app/tasks |
Per-task clones, mounted at the identical path inside and out | Yes while tasks are in flight: an unpushed attempt's commits exist only here |
/app/secrets |
global.env, rendered by a secret store, mounted read-only |
No: the secret store re-syncs it |
/app/.env |
Local config (.env.example is the contract) |
Yes (small, not in git) |
image, node_modules, state/home/.npm |
Build output and caches | No, disposable |
HOME is set explicitly in compose, and the image's marek has
/app/state/home as its passwd home too, because ssh reads ~/.ssh from
passwd and ignores $HOME. Every path the work CLI derives from HOME goes
through app/src/paths.ts (workspaceRoot(), claudeConfigPath()); compose
sets WORK_WORKSPACE_ROOT=/app/workspace, so a project's localPath
(my-org/foreman) resolves to /app/workspace/my-org/foreman.
CLAUDE_BIN is pinned to the image's claude.
Seeding is scripts/bootstrap-container-state.sh, run on the host as
uid 1000 (not root, so ownership is right without a chown). It is
idempotent: an existing non-empty file is never overwritten without
--force, a second run reports 0 change(s), and it never prints a file's
contents. It:
- creates
state/home(mode 700) andworkspace/; - copies the key pair and
known_hostsfromSSH_KEY(default~/.ssh/id_ed25519; decision 4 keeps the operator's key for now); - writes
user.name/user.emailfromGIT_USER_NAME/GIT_USER_EMAIL, falling back to the invoking user's owngit config --global; - copies
~/.claudeexcept the stored login (.credentials.json; the containers useCLAUDE_CODE_OAUTH_TOKEN) and per-process runtime state, plus~/.claude.json. Files the user cannot read (a few of peter's pre-#477 transcripts on foreman) are reported and skipped; - with
--migrate-workspace(by hand only since a past task;deploy.shno longer passes it), renames~/projectsto/app/workspaceand leaves a symlink, and aliases the old path's transcript directories under the new path's name so chat sessions started there still resume; - with
--refresh-sessions, updates transcripts that are newer on the host. Run it at the cutover after the units stop, so a session that kept going after the first seed resumes from its end. - with
--merge-seed <tarball>, does only this and stops: merges a tarball of another seat's~/.claude/projects(top-level dirprojects/) intostate/home/.claudewith GNU tar's--skip-old-files(no existing file is overwritten) and--keep-directory-symlink(the-app-workspace-*aliases stay symlinks), then aliases the seed's-home-marek-projects-*directories. SetSEED_WORKSPACE=/home/marek/projects, the path those sessions ran in, when running it from inside a container. Used once on 2026-09-30 to bring over peter's pre-cutover license-2 transcripts (203 files), so a rework of a task like #500 can--resumeits retained session.
Claude rules mirror. The bootstrap copied ~/.claude once,
so later rule edits never reached the containers. Now four entries are a
mirror of marek's ~/.claude: CLAUDE.md, settings.json, agents/ and
skills/ (including skills/synced). Nothing else is mirrored: not the
login, not projects/, plugins/ or runtime state.
scripts/sync-claude-rules.sh does it: a dry run by default, --apply to
write. A file that differs is replaced atomically (temp file in the same
directory, then mv), so a claude process starting mid-sync never reads half
a file; anything under the destination's agents//skills/ that is gone
from the source is deleted. It never prints file contents, and refuses any
uid but 1000 and any run with FOREMAN_SERVICE set (inside a container).
It runs every 15 minutes from marek's host crontab
(scripts/install-claude-rules-cron.sh, log state/logs/sync-claude-rules.log)
and on every host scripts/deploy.sh. So a shared-rule change flows
homelab's claude-config/ -> sync-claude-config.sh -> marek's
~/.claude -> the containers within 15 minutes. Edit the rules in
claude-config/ (or marek's ~/.claude for settings.json), never in
state/home/.claude: the next run overwrites it. Tests:
scripts/sync-claude-rules.test.mjs.
On a fresh machine: follow "Rebuild foreman from scratch" at the top of this README.
GitHub token (#516): scripts/set-github-token.sh is run by the
operator from their own device (setting a value in the secret store is
admin-only, and admin means a request through its admin proxy at
KEYRING_URL, which the script requires; an agent gets 403). It reads the PAT from stdin only (hidden prompt, or a
pipe), never echoes it, checks it against GitHub, declares GH_TOKEN in a
new foreman-github group for foreman (or replaces the value if declared),
syncs foreman, then runs gh auth status and an https git ls-remote in a
one-off container over ssh foreman. --dry-run does everything except
the store write and sync, handing the token to the test container over
ssh's stdin instead. A failure names the step (read, github, store-check,
store, sync, verify) and never quotes the token. Tests:
scripts/set-github-token.test.mjs (run by cd app && npm test).
A task labelled root-access in pinboard, with a CTID scope, can be granted
temporary root on those containers: the operator picks a TTL when queueing it,
and pinboard records the grant. The host side is rootproxy
(homelab/rootproxy/, a past task), a forced command on root's
authorized_keys that re-checks the grant on every call. work root (task
#266, app/src/root.ts) is how the run itself uses it:
work root status|start|shutdown|reboot <ctid>
work root exec <ctid> '<shell command>'
work root set <ctid> <key> <value>
work root exec --detach <ctid> '<shell command>' -> job id
work root wait <ctid> <job-id>
- Task id. Read from
WORK_TASK_ID, which the runner sets on every spawn. If it is missing (runs before a past task went throughsudo -u, whoseenv_resetdropped it),work rootfalls back to the task id in its cwd (/app/tasks/task-<id>/...). - Refuses locally before touching the host when the task has no
root-accesslabel, no active grant, an expired grant (said as "expired", so the agent stops instead of retrying), a CTID outside the grant, or a verb orsetkey rootproxy would not accept anyway. Exit 2 for a local refusal; the proxy's own refusals come back as exit 77, and ssh failures as 255. This side is defence in depth only: the grant check that matters is the one on the host. - Prompt. A
root-accesstask's prompt lists the CTIDs in scope, the expiry and the verbs (rootAccessLinesinrunner.ts). No other task's prompt mentions root. - SSH.
ssh -o BatchMode=yes root@homelab <task> <verb> <ctid> ..., using the calling user's default key. Both seats' Linux users on foreman hold the same key, which is the one installed in root'sauthorized_keysas rootproxy's forced command.WORK_ROOT_HOSToverrides the destination (tests only, in practice). - Slow commands run detached.
exec --detachstarts the command undernohup setsidinside the container, with output and exit code in/root/rootjobs/<job>.{log,rc}, so it survives the ssh connection dropping (the router kills long-lived connections, and an apt upgrade killed midway is the worst outcome here).work root waitis one proxyexecthat blocks inside the container for up to 29 minutes, then prints the log tail and exits with the job's code, or 75 if it is still running. Waiting inside one tool call costs no tokens; only the agent's turns do. The first #206 run backgrounded its upgrade itself and ended its session while apt was still running, which is what this replaces. - Long waits are not counted against the run. The runner passes
BASH_DEFAULT_TIMEOUT_MS/BASH_MAX_TIMEOUT_MSof 30 minutes (in the env and again through--settings), and its hard timeout pauses while/app/tasks/task-<id>/.root-waitnames a livework root waitprocess, the same way it pauses for an unanswered question. - Audit. Every call, allowed or refused, is in the host journal
(
journalctl -t rootproxy),/var/log/rootproxy.log, and a comment on the task. - No commits is not a failure here. A session that commits nothing
normally goes to rework ("made no commits"). A task labelled
root-accessorno-repo-change(a past task,decideNoCommitOutcomeinrunner.ts) goes to review with a success comment instead, as long as its handoff fills in both Completed and Verified; a blank one still goes to rework. Review rather than done, since no PR, CI or deploy backs the claim up. Any task, labelled or not, can also end with the handoff'soutcomeset:already_done(the work was already on main, an alert had already cleared) closes it as done if Verified is filled in, andrejected(a task that says to reject it when it does not apply, such as an npm consumer bump) closes it as rejected if Completed gives the reason. Without that evidence it goes to rework as before. These used to sit in rework until a human closed them, which stalled stories behind them.
Matches pinboard-initial-idea.md's "Two access modes": at a desk, VSCode
Remote-SSH into this LXC (ssh foreman already works from the desktop -
point Remote-SSH at host foreman, no extra config needed); away from
one, Claude remote access. The WSL desktop keeps its own repo clones and
SSH access to every LXC as a fallback, per the workspace AGENTS.md.
cd app
npm install
npm run typecheck
npm test # also runs scripts/*.test.mjs
The services never run a host build: work is compiled into the image, so
what runs is exactly what scripts/deploy.sh last built from /app. The
old "two clones, one npm link" gotcha (the global work pointed at
whichever checkout last ran npm link, so a rebuild in the other one
silently changed nothing) no longer affects the runner.
For the operator's own work on the host, npm link from app/ in the
workspace clone (/app/workspace/<org>/foreman, which
~/projects points at) after npm run build; docker compose exec dispatch work <args> works without any host install.
The unit files stay in systemd/ for this, with
scripts/install-dispatch-service.sh to install them. scripts/deploy.sh
retires any unit it finds installed, so roll the code back first:
- Revert the #518 merge on
mainand pull it into/app(git revert <merge sha> && git push, thengit pullin/app). Do not run the old deploy.sh yet. - Stop the containers, each once idle (
docker compose ps, thendocker compose stop dispatch chat pipeline;downalso works). scripts/install-dispatch-service.sh, which copies the three units into~/.config/systemd/userandenable --nows them. The old deploy.sh rebuilds the host's/app/appbuild they run from:cd /app/app && npm ci && npm run build && npm link.- The housekeeping timer's units were deleted in #518; restore them with
git show <merge sha>^:systemd/foreman-housekeeping.timer(and.service, andscripts/install-housekeeping-timer.sh), and remove the# foreman-housekeepinglines fromcrontab -e.
~/projects stays a symlink to /app/workspace; the units work through it
unchanged.
.claude/settings.json denies Claude Code from reading or editing
.env/.env.*. When adding a credential, put the real value in .env
and reference it as ${VAR}.
Do not run a recursive chown on /app.
If foreman saves you time, you can support its development on GitHub Sponsors or Ko-fi: