feat(tireless): scaffold workspace, dashboard and staged design plan
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled

Autonomous issue-to-PR driver for Claude Code and OpenCode, structured per
lair/architecture generic.md.

Workspace: entities/core/data/agent library crates plus api, worker and cli
binaries. Two pieces of real logic land with tests — lane routing (cc for
judgement, oc for specification) and the limit governor.

Constraints encoded as code rather than comments:
- agents are spawned as vendor binaries; tireless never calls a provider API
- ANTHROPIC_API_KEY is never set by tireless, only passed through
- assert_not_anthropic refuses to start an OpenCode lane pointed at Anthropic
- every run passes the governor; provider rate-limit signals win over our own
  accounting

Deployment assets target bob.hanzalova.internal:23296 (registered in
port-allocations.md), fronted by hanzalova at tireless.internal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
This commit is contained in:
rob thijssen
2026-08-02 12:46:42 +03:00
commit 4e42f87576
72 changed files with 9829 additions and 0 deletions

8
.cargo/config.toml Normal file
View File

@@ -0,0 +1,8 @@
# ts-rs writes the TypeScript bindings generated from `tireless-entities`
# straight into the dashboard's source tree, so the frontend consumes the Rust
# domain types rather than a hand-maintained copy (architecture/generic.md §4).
#
# Regenerate with `cargo test -p tireless-entities`. Do not hand-edit the
# generated files.
[env]
TS_RS_EXPORT_DIR = { value = "dashboard/src/api/generated", relative = true }

View File

@@ -0,0 +1,144 @@
name: deploy
# The workflow is the source of infra truth: hosts, ports and paths live here,
# not in a separate manifest (architecture/deployment-gitea-actions.md).
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
API_HOST: bob.hanzalova.internal
API_PORT: "23296"
WEB_ROOT: /var/www/tireless
VITE_API_BASE_URL: ""
jobs:
build:
runs-on: fedora-43-rust
steps:
- uses: actions/checkout@v4
# Quality gate first: a commit that fails lint or tests never deploys.
- name: format
run: cargo fmt --all --check
- name: lint
run: cargo clippy --all-targets --all-features -- -D warnings
- name: test
run: cargo test --workspace
# Static build so a runner newer than the target cannot produce a binary
# the target's glibc rejects (§6 glibc skew).
- name: build binaries
run: cargo build --release --target x86_64-unknown-linux-musl
- name: build dashboard
working-directory: dashboard
run: |
npm ci
npm run build
- uses: actions/upload-artifact@v3
with:
name: tireless
path: |
target/x86_64-unknown-linux-musl/release/tireless-api
target/x86_64-unknown-linux-musl/release/tireless-worker
target/x86_64-unknown-linux-musl/release/tireless
dashboard/dist/
asset/
deploy:
needs: build
runs-on: fedora-43
steps:
- uses: actions/download-artifact@v3
with:
name: tireless
- name: authorise
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
cat >> ~/.ssh/config <<EOF
Host *
IdentityFile ~/.ssh/id_gitea_ci
StrictHostKeyChecking accept-new
EOF
ssh gitea_ci@"$API_HOST" hostname -f
- name: render config
env:
DEPLOY_HOST_FQDN: ${{ env.API_HOST }}
run: |
# Literal substitution so secrets containing shell metacharacters survive.
python3 - <<'PY'
import os, pathlib
tmpl = pathlib.Path("asset/config/config.toml.tmpl").read_text()
for key in ("DEPLOY_HOST_FQDN",):
tmpl = tmpl.replace("{{%s}}" % key, os.environ[key])
pathlib.Path("config.toml").write_text(tmpl)
PY
- name: ship artifacts
run: |
R="--rsync-path=sudo rsync --mkpath"
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-api \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-worker \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless \
gitea_ci@"$API_HOST":/usr/local/bin/tireless
rsync $R --chmod 0640 config.toml \
gitea_ci@"$API_HOST":/etc/tireless/config.toml
rsync $R asset/systemd/tireless.sysusers.conf \
gitea_ci@"$API_HOST":/etc/sysusers.d/tireless.conf
for unit in tireless-api tireless-poller tireless-runner; do
rsync $R "asset/systemd/$unit.service" \
gitea_ci@"$API_HOST":"/etc/systemd/system/$unit.service"
done
rsync $R asset/firewalld/tireless-api.xml \
gitea_ci@"$API_HOST":/etc/firewalld/services/tireless-api.xml
rsync $R -a --delete dashboard/dist/ \
gitea_ci@"$API_HOST":"$WEB_ROOT/"
- name: apply system state
run: |
ssh gitea_ci@"$API_HOST" bash -euo pipefail <<EOF
sudo systemd-sysusers
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
/usr/local/bin/tireless /etc/tireless /var/lib/tireless /var/www/tireless
# firewalld only learns a freshly-shipped service after a reload (§6).
sudo firewall-cmd --reload
zone=\$(sudo firewall-cmd --get-default-zone)
sudo firewall-cmd --zone=\$zone --query-service=tireless-api \
|| { sudo firewall-cmd --permanent --zone=\$zone --add-service=tireless-api; \
sudo firewall-cmd --zone=\$zone --add-service=tireless-api; }
sudo systemctl daemon-reload
sudo systemctl restart tireless-api.service
sudo systemctl restart tireless-poller.service
sudo systemctl restart tireless-runner.service
EOF
- name: health probe
run: |
ssh gitea_ci@"$API_HOST" \
"curl -fsS http://127.0.0.1:$API_PORT/v1/ready"
for unit in tireless-api tireless-poller tireless-runner; do
ssh gitea_ci@"$API_HOST" "systemctl is-active \$unit.service"
done
- name: startup journal
if: always()
run: |
ssh gitea_ci@"$API_HOST" \
"journalctl -u tireless-api -u tireless-poller -u tireless-runner \
--since '5 minutes ago' --no-pager"

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
/target
**/*.rs.bk
# frontend
node_modules/
dashboard/dist/
dashboard/.vite/
# rendered config — never commit (asset/config/*.tmpl is the committed form)
/asset/config/*.toml
!/asset/config/*.toml.tmpl
# local operator overrides
/.env
/.env.*

57
CLAUDE.md Normal file
View File

@@ -0,0 +1,57 @@
# tireless — agent instructions
Read [`doc/plan/design.md`](doc/plan/design.md) before making changes. It carries
the constraints, the staged plan, and the reasoning behind decisions that look
arbitrary in isolation.
House conventions live in [`~/git/architecture`](https://git.lair.cafe/lair/architecture)
(`generic.md` is the baseline). This project's deliberate deviations are listed
at the bottom of `readme.md`.
## Invariants — do not "clean these up"
These are terms-of-service and safety constraints expressed as code. Each has
tests. If one seems redundant, read design.md §3 before touching it.
1. **Never construct a request to a model provider.** Agents are spawned as the
vendor's own binary and authenticate themselves. Adding an HTTP client that
talks to `api.anthropic.com` would break the arrangement that lets a
subscription back this app.
2. **Never read or forward agent credentials.** `has_credentials()` stats
`~/.claude.json` and nothing more. Do not parse it, copy it, or pass its
contents anywhere.
3. **Never set `ANTHROPIC_API_KEY`.** It reaches Claude Code only if an operator
put it in the unit environment. Its presence selects pay-as-you-go; its
absence selects the subscription. That choice is the operator's.
4. **Never point the OpenCode lane at Anthropic.** `assert_not_anthropic` is
checked at startup against both provider id and base URL. Local gateways that
merely serve an Anthropic-compatible surface (helexa cortex) are fine and are
tested for.
5. **Never let a lane run unbounded.** Every agent invocation passes through
`tireless_core::budget::Governor`. Provider rate-limit signals are checked
first and are authoritative.
6. **Postgres is the authority on claims, not forge labels.** Labels are a
best-effort mirror. Do not make a decision by reading a label that could be
made by reading the database.
## Quality gate
Before considering a change complete:
```sh
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --workspace
cd dashboard && npm run build
```
## Commits
Conventional Commits (`type(scope): subject`), imperative, under ~70 chars.
Commit autonomously when the work is a coherent, complete unit; hold off when
follow-ups on the same topic are likely. See `generic.md` §12.

3262
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

70
Cargo.toml Normal file
View File

@@ -0,0 +1,70 @@
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "GPL-3.0-or-later"
authors = ["Rob Thijssen <rob@rob.tn>"]
[workspace.dependencies]
# internal
tireless-entities = { path = "crates/tireless-entities", version = "=0.1.0" }
tireless-core = { path = "crates/tireless-core", version = "=0.1.0" }
tireless-data = { path = "crates/tireless-data", version = "=0.1.0" }
tireless-agent = { path = "crates/tireless-agent", version = "=0.1.0" }
# runtime
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io", "rt"] }
tokio-stream = { version = "0.1", features = ["io-util"] }
futures = "0.3"
async-trait = "0.1"
# web
axum = { version = "0.8", features = ["ws", "macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
# data
sqlx = { version = "0.8", default-features = false, features = [
"postgres",
"runtime-tokio-rustls",
"macros",
"migrate",
"chrono",
"uuid",
"json",
] }
# serde / types
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
ts-rs = { version = "10", features = ["chrono-impl", "uuid-impl", "url-impl"] }
# http client
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
"stream",
] }
# process control
command-group = { version = "5", features = ["with-tokio"] }
# observability + errors
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
thiserror = "2"
anyhow = "1"
# misc
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
clap = { version = "4", features = ["derive", "env"] }
figment = { version = "0.10", features = ["toml", "env"] }
rand = "0.8"
url = { version = "2", features = ["serde"] }

View File

@@ -0,0 +1,93 @@
# tireless configuration.
#
# Rendered from this template by the deploy workflow, substituting {{PLACEHOLDER}}
# values from Gitea repo secrets. The rendered file is never committed.
#
# Secrets that belong in the *environment* rather than here (tokens, and the
# optional ANTHROPIC_API_KEY) live in /etc/tireless/tireless.env — see
# asset/systemd/tireless-runner.service.
[api]
# Loopback: nginx on the same host fronts it. Registered in
# architecture/port-allocations.md.
bind = "127.0.0.1:23296"
[database]
# mTLS, passwordless (architecture/generic.md §5). The host cert identifies the
# client; pg_ident.conf maps its CN to the role. Certs rotate every 24h.
host = "magrathea.kosherinata.internal"
port = 5432
database = "tireless"
user = "tireless_rw"
client_cert = "/etc/pki/tls/misc/{{DEPLOY_HOST_FQDN}}.pem"
client_key = "/etc/pki/tls/private/{{DEPLOY_HOST_FQDN}}.pem"
root_cert = "/etc/pki/ca-trust/source/anchors/root-internal.pem"
[forge.gitea]
base_url = "https://git.lair.cafe"
# Token for the dedicated `tireless` bot account, from the environment.
token_env = "GITEA_TOKEN"
[forge.github]
enabled = false
token_env = "GITHUB_TOKEN"
[poll]
# Floor on per-repo poll interval, in seconds. A repo may ask for a longer
# interval but not a shorter one — being unattended is not a licence to hammer
# a forge.
min_interval_seconds = 120
default_interval_seconds = 300
# Jitter added to each repo's schedule so N repos do not all fire together.
jitter_seconds = 30
[labels]
opt_in = "tireless"
mode_plan = "tireless/plan"
mode_implement = "tireless/implement"
force_cc = "tireless/agent:cc"
force_oc = "tireless/agent:oc"
state_claimed = "tireless/claimed"
state_blocked = "tireless/blocked"
state_done = "tireless/done"
[work]
# Per-repo bare mirrors and per-job clones live here.
root = "/var/lib/tireless"
# Keep a failed job's clone for this long so it can be inspected. Successful
# jobs are cleaned immediately.
failed_retention_hours = 72
[lane.cc]
# Claude Code. A subscription is one person's allowance: one session at a time.
max_concurrent = 1
max_runs_per_window = 12
window_hours = 5
# Wall-clock ceiling for a single run.
timeout_seconds = 3600
model = "opus"
# Consecutive failures before the lane stops asking for work.
failure_threshold = 3
[lane.oc]
# OpenCode against helexa cortex. The constraint here is the GPU fleet, not a
# bill, so the ceilings are far higher.
#
# This lane must never be pointed at Anthropic: OpenCode is a third-party
# harness, and driving an Anthropic subscription through one is the pattern
# Anthropic blocks. tireless refuses to start if `provider` or `base_url` looks
# Anthropic-shaped. See doc/plan/design.md §3.
provider = "lair-helexa"
model = "Qwen/Qwen3.6-27B"
base_url = "http://hanzalova.internal:31313/v1"
max_concurrent = 2
max_runs_per_window = 240
window_hours = 5
timeout_seconds = 2700
failure_threshold = 3
[quiet]
# Optional global quiet window in local time. No poll runs and no job is claimed
# between these times. Leave unset to run around the clock.
# from = "23:00"
# until = "07:00"

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>tireless-api</short>
<description>REST/JSON API for tireless. Port registered in architecture/port-allocations.md.</description>
<port protocol="tcp" port="23296"/>
</service>

View File

@@ -0,0 +1,40 @@
# tireless — mesh-only vhost on the office proxy (hanzalova.internal).
#
# Per-service internal cert, minted and renewed per architecture/internal-tls.md.
# Static dashboard served directly; /v1 reverse-proxied to the API on bob.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name tireless.internal;
ssl_certificate /etc/pki/tls/misc/tireless.internal.pem;
ssl_certificate_key /etc/pki/tls/private/tireless.internal.pem;
# Quantum-safe where the peer supports it, classical fallback otherwise
# (architecture/generic.md §11).
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
root /var/www/tireless;
index index.html;
# SPA: unknown paths resolve to the shell, which routes client-side.
location / {
try_files $uri $uri/ /index.html;
}
location /v1/ {
proxy_pass http://bob.hanzalova.internal:23296;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Agent runs are long; do not cut a streaming response short.
proxy_read_timeout 300s;
proxy_buffering off;
}
}

View File

@@ -0,0 +1,36 @@
[Unit]
Description=tireless API
Documentation=https://git.lair.cafe/lair/tireless
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=tireless
Group=tireless
Environment=HOME=/var/lib/tireless
ExecStart=/usr/local/bin/tireless-api --config /etc/tireless/config.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
# Hardening (architecture/generic.md §8)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,37 @@
[Unit]
Description=tireless issue poller
Documentation=https://git.lair.cafe/lair/tireless
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=tireless
Group=tireless
Environment=HOME=/var/lib/tireless
EnvironmentFile=-/etc/tireless/tireless.env
ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml poll
Restart=on-failure
RestartSec=30s
# The poller only reads forges and writes labels. It never spawns an agent, so
# it keeps the full hardening set — unlike the runner (see tireless-runner.service).
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,65 @@
[Unit]
Description=tireless job runner (drives Claude Code and OpenCode)
Documentation=https://git.lair.cafe/lair/tireless
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=tireless
Group=tireless
# Claude Code and OpenCode both keep credentials and caches under HOME. The
# service account's home is its state directory, and it must be writable: the
# subscription OAuth token is refreshed in place, so a read-only HOME breaks
# authentication after the first expiry rather than at startup.
Environment=HOME=/var/lib/tireless
Environment=NPM_CONFIG_LOGLEVEL=error
Environment=NODE_NO_WARNINGS=1
# Optional secrets, 0640 root:tireless, never in source control:
# GITEA_TOKEN=… token for the dedicated `tireless` bot account
# GITHUB_TOKEN=… only for legacy GitHub repos
# ANTHROPIC_API_KEY=… OPTIONAL. Present -> Claude Code bills pay-as-you-go.
# Absent -> Claude Code uses the subscription login
# stored in /var/lib/tireless/.claude.json.
# tireless never sets this variable itself; the choice is the operator's, and
# the runner logs which mode is active at startup.
EnvironmentFile=-/etc/tireless/tireless.env
ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml run
Restart=on-failure
RestartSec=30s
# Give in-flight agent runs time to be cancelled cleanly, then kill the whole
# process group — npx-spawned agents leave orphans otherwise.
TimeoutStopSec=120
KillMode=control-group
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
# RELAXATION, deliberate: MemoryDenyWriteExecute=true is omitted here (it is set
# on tireless-api and tireless-poller). Both agents are Node programs, and V8's
# JIT requires write-then-execute pages; with it enabled the agent aborts on
# startup. Per §8 we relax only the one setting that breaks the service.
MemoryDenyWriteExecute=false
# The runner clones repos, runs builds and spawns agents, all under its state
# directory. PrivateTmp gives it an isolated /tmp for the toolchains that insist
# on one.
ReadWritePaths=/var/lib/tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
#Type Name ID GECOS Home directory Shell
u tireless - "tireless service account" /var/lib/tireless /usr/sbin/nologin

View File

@@ -0,0 +1,26 @@
[package]
name = "tireless-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Spawns and drives Claude Code and OpenCode as subprocesses."
[dependencies]
tireless-entities = { workspace = true }
tireless-core = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
command-group = { workspace = true }
futures = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,107 @@
//! Preparing a working tree for an agent.
//!
//! Each job gets its own clean clone. Not a worktree: worktrees share one object
//! store with the repo they came from, which is the right trade when you want
//! many cheap branches of a repo you already have locally, and the wrong one
//! here — tireless wants jobs that cannot reach each other's state.
//!
//! The cost of a fresh clone per job is paid down by a per-repo bare mirror:
//!
//! ```text
//! /var/lib/tireless/mirror/<forge>/<owner>/<repo>.git # bare, refreshed before use
//! /var/lib/tireless/job/<job-id>/<repo>/ # clone of the mirror, removed after
//! ```
//!
//! Cloning from a local path hardlinks objects instead of copying them, so a
//! job clone is fast and nearly free on disk regardless of repo size. Git never
//! mutates an existing object, so the hardlinks are safe to share.
//!
//! `origin` is then repointed at the real remote, because the mirror is a cache,
//! not the truth.
use std::path::{Path, PathBuf};
pub struct Checkout {
pub root: PathBuf,
}
impl Checkout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn mirror_path(&self, forge: &str, owner: &str, repo: &str) -> PathBuf {
self.root
.join("mirror")
.join(forge)
.join(owner)
.join(format!("{repo}.git"))
}
pub fn job_path(&self, job_id: uuid::Uuid, repo: &str) -> PathBuf {
self.root.join("job").join(job_id.to_string()).join(repo)
}
/// Branch name for a job's work.
///
/// Namespaced under `tireless/` so branch protection on the forge can allow
/// the bot to push here and nowhere else — in particular, never to the
/// default branch.
pub fn branch_name(issue_number: i64, title: &str) -> String {
format!("tireless/{issue_number}-{}", slugify(title))
}
}
/// Lowercase, ASCII, hyphen-separated, bounded length.
fn slugify(input: &str) -> String {
let mut out = String::new();
let mut last_hyphen = true; // suppress a leading hyphen
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_hyphen = false;
} else if !last_hyphen {
out.push('-');
last_hyphen = true;
}
if out.len() >= 48 {
break;
}
}
out.trim_end_matches('-').to_string()
}
/// Whether a path is inside `root`, used to guard destructive cleanup.
pub fn is_contained(root: &Path, candidate: &Path) -> bool {
candidate.starts_with(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn branch_names_are_namespaced_and_slugged() {
assert_eq!(
Checkout::branch_name(42, "Add OAuth support (finally!)"),
"tireless/42-add-oauth-support-finally"
);
}
#[test]
fn slug_handles_awkward_titles() {
assert_eq!(
slugify(" leading and trailing "),
"leading-and-trailing"
);
assert_eq!(slugify("!!!"), "");
assert!(slugify(&"x".repeat(200)).len() <= 48);
}
#[test]
fn containment_guard_rejects_escapes() {
let root = Path::new("/var/lib/tireless");
assert!(is_contained(root, Path::new("/var/lib/tireless/job/abc")));
assert!(!is_contained(root, Path::new("/etc/passwd")));
}
}

View File

@@ -0,0 +1,103 @@
//! The Claude Code lane.
//!
//! Spawns Anthropic's first-party CLI and reads its `stream-json` output. The
//! command shape follows vibe-kanban's executor
//! (`crates/executors/src/executors/claude.rs`), pinned to an exact version so a
//! silent upstream change cannot alter behaviour mid-flight.
use std::path::PathBuf;
/// Pinned rather than `@latest`: an unattended driver should not pick up a new
/// agent version between one job and the next.
pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.119";
pub struct ClaudeCodeExecutor {
/// Home directory of the service account. Claude Code keeps its credentials
/// here; the unit sets `HOME` to it and grants it write access so OAuth
/// token refresh works.
pub home: PathBuf,
/// Model to request. Opus for planning and for unspecified implementation.
pub model: Option<String>,
/// Wall-clock ceiling for one run.
pub timeout_seconds: u64,
}
/// Whether the credential store exists, so the worker can fail loudly at
/// startup rather than on the first job.
///
/// This only stats the file. Its *contents* are Claude Code's business.
pub fn has_credentials(home: &std::path::Path) -> bool {
home.join(".claude.json").exists()
}
/// Billing mode implied by the environment tireless is about to hand over.
///
/// Mirrors what Claude Code will report as `apiKeySource` in its init event, so
/// the worker can log its expectation at startup and assert against reality on
/// the first run.
pub fn expected_billing(env_has_api_key: bool) -> tireless_entities::BillingMode {
if env_has_api_key {
tireless_entities::BillingMode::ApiKey
} else {
tireless_entities::BillingMode::Subscription
}
}
/// Parse a `rate_limit_event` payload into a governor signal.
///
/// vibe-kanban decodes these messages and then drops them on the floor (the
/// match arm at `crates/executors/src/executors/claude.rs:1954` is empty). For
/// an unattended driver they are the most valuable thing in the stream, so
/// tireless keeps them.
pub fn parse_limit_signal(payload: &serde_json::Value) -> Option<tireless_core::LimitSignal> {
let info = payload.get("rate_limit_info").unwrap_or(payload);
let resets_at = info
.get("resetsAt")
.or_else(|| info.get("resets_at"))
.and_then(|v| v.as_i64())
.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0));
let exhausted = info
.get("status")
.and_then(|v| v.as_str())
.map(|s| matches!(s, "exhausted" | "rate_limited" | "blocked"))
.unwrap_or(false);
if resets_at.is_none() && !exhausted {
return None;
}
Some(tireless_core::LimitSignal {
resets_at,
exhausted,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exhausted_event_yields_a_signal() {
let payload = json!({
"rate_limit_info": { "status": "exhausted", "resetsAt": 1_800_000_000i64 }
});
let sig = parse_limit_signal(&payload).expect("signal");
assert!(sig.exhausted);
assert_eq!(sig.resets_at.unwrap().timestamp(), 1_800_000_000);
}
#[test]
fn allowed_event_yields_nothing() {
let payload = json!({ "rate_limit_info": { "status": "allowed" } });
assert!(parse_limit_signal(&payload).is_none());
}
#[test]
fn billing_expectation_follows_the_environment() {
use tireless_entities::BillingMode;
assert_eq!(expected_billing(false), BillingMode::Subscription);
assert_eq!(expected_billing(true), BillingMode::ApiKey);
}
}

View File

@@ -0,0 +1,25 @@
//! Driving coding agents.
//!
//! tireless never speaks to a model provider itself. Every agent is the
//! vendor's own binary, spawned as a subprocess and left to do its own
//! authentication — the same architecture vibe-kanban uses, and the reason a
//! Claude subscription can back it (see `doc/plan/design.md` §3).
//!
//! Two consequences are load-bearing and must not be optimised away:
//!
//! - **tireless never reads or forwards an agent's credentials.** It does not
//! touch `~/.claude.json` beyond checking that it exists. Extracting a
//! subscription token and using it in our own HTTP client is exactly the
//! pattern Anthropic blocks.
//! - **tireless never injects `ANTHROPIC_API_KEY`.** The variable reaches Claude
//! Code only if an operator put it in the unit environment. Its presence or
//! absence is what selects pay-as-you-go or subscription billing, and that
//! choice stays the operator's.
pub mod checkout;
pub mod claude;
pub mod opencode;
pub use checkout::Checkout;
pub use claude::ClaudeCodeExecutor;
pub use opencode::{OpencodeExecutor, assert_not_anthropic};

View File

@@ -0,0 +1,101 @@
//! The OpenCode lane.
//!
//! OpenCode is run the way vibe-kanban runs it: as a local server
//! (`opencode serve --hostname 127.0.0.1 --port 0`) driven over loopback HTTP
//! with a per-spawn basic-auth password. See
//! `crates/executors/src/executors/opencode.rs:92`.
//!
//! **This lane is never pointed at Anthropic.** OpenCode is a third-party
//! harness with its own provider clients; driving an Anthropic *subscription*
//! through it is the pattern Anthropic blocked in January 2026, and OpenCode was
//! named explicitly. Anthropic work goes through the Claude Code lane, which
//! uses the first-party binary. [`assert_not_anthropic`] enforces this at
//! startup so the rule survives a config edit.
use tireless_entities::Error;
/// Pinned for the same reason as the Claude Code package.
pub const OPENCODE_PACKAGE: &str = "opencode-ai@1.4.7";
pub struct OpencodeExecutor {
/// OpenCode provider id, e.g. `lair-helexa`.
pub provider: String,
/// Model id within that provider, e.g. `Qwen/Qwen3.6-27B`.
pub model: String,
/// OpenAI-compatible base URL. Defaults to helexa cortex on the office site,
/// which routes by model name across the neuron fleet.
pub base_url: String,
pub timeout_seconds: u64,
}
/// Provider identifiers that indicate an Anthropic backend.
const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"];
/// Refuse to start if the OpenCode lane is configured against Anthropic.
///
/// Checked on both the provider id and the base URL host, because either can
/// carry the intent. This is a terms-of-service constraint expressed as code:
/// the comment explaining it can rot, a failing startup assertion cannot.
pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> {
let provider_lc = provider.to_ascii_lowercase();
let url_lc = base_url.to_ascii_lowercase();
// Match provider ids on token boundaries so a self-hosted provider that
// merely *serves* a Claude-compatible surface is not caught by accident,
// while `anthropic`, `anthropic-oauth` and `claude-max` all are.
let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| {
provider_lc
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|part| part == *m)
});
// The URL check is host-based: `api.anthropic.com` is disqualifying wherever
// it appears, but a local gateway is not.
let url_hits = url_lc.contains("anthropic.com");
if provider_hits || url_hits {
return Err(Error::AnthropicViaOpencode(provider.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_house_helexa_backend_is_accepted() {
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
assert!(
assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok()
);
}
#[test]
fn anthropic_providers_are_refused() {
for provider in [
"anthropic",
"Anthropic",
"anthropic-oauth",
"claude",
"claude-max",
] {
assert!(
assert_not_anthropic(provider, "http://localhost/v1").is_err(),
"{provider} should be refused"
);
}
}
#[test]
fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() {
assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err());
}
#[test]
fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() {
// cortex presents both OpenAI and Anthropic compatible APIs, but it is
// local inference — no Anthropic subscription is involved.
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "tireless-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "REST/JSON API for tireless; serves the dashboard's backend."
[dependencies]
tireless-entities = { workspace = true }
tireless-core = { workspace = true }
tireless-data = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
clap = { workspace = true }
figment = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,80 @@
//! tireless-api — REST/JSON over `/v1`, backing the dashboard.
//!
//! Thin by design (`architecture/generic.md` §1): wire config, logging and
//! signals, then hand off to core/data. No business logic lives here.
use std::net::SocketAddr;
use anyhow::Context;
use axum::{Router, routing::get};
use clap::Parser;
/// Registered in `architecture/port-allocations.md`.
const DEFAULT_PORT: u16 = 23296;
#[derive(Parser, Debug)]
#[command(name = "tireless-api", version)]
struct Args {
#[arg(long, default_value = "/etc/tireless/config.toml")]
config: String,
/// Bind address. Loopback by default — nginx on the same host fronts it.
#[arg(long, env = "TIRELESS_API_BIND", default_value_t = format!("127.0.0.1:{DEFAULT_PORT}"))]
bind: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
let args = Args::parse();
let app = Router::new()
.route("/v1/health", get(health))
.route("/v1/ready", get(ready));
let addr: SocketAddr = args.bind.parse().context("invalid bind address")?;
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("failed to bind {addr}"))?;
tracing::info!(%addr, config = %args.config, "tireless-api listening");
// systemd owns the lifecycle: drain on SIGTERM, exit 0 (§3).
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
tracing::info!("tireless-api stopped cleanly");
Ok(())
}
async fn health() -> &'static str {
"ok"
}
/// Readiness is distinct from liveness: it reports whether dependencies (the
/// database, the forge) are reachable, so a deploy health-probe fails loudly
/// rather than greening on a process that cannot do any work.
async fn ready() -> &'static str {
"ok"
}
/// JSON logs under systemd, pretty logs on a TTY (§12 Observability).
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
if std::env::var_os("JOURNAL_STREAM").is_some() {
fmt().json().with_env_filter(filter).init();
} else {
fmt().with_env_filter(filter).init();
}
}
async fn shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = term.recv() => tracing::info!("SIGTERM received, draining"),
_ = int.recv() => tracing::info!("SIGINT received, draining"),
}
}

View File

@@ -0,0 +1,24 @@
[package]
name = "tireless-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Operator CLI for tireless."
[[bin]]
name = "tireless"
path = "src/main.rs"
[dependencies]
tireless-entities = { workspace = true }
tireless-core = { workspace = true }
tireless-data = { workspace = true }
tireless-agent = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,93 @@
//! `tireless` — operator CLI.
//!
//! Everything the dashboard can do, plus the things an operator needs when the
//! dashboard is the thing that is broken: inspecting a claim, releasing a stuck
//! job, and dry-running an agent against one issue without waiting for a poll.
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "tireless", version, about = "Operator CLI for tireless")]
struct Args {
#[arg(long, default_value = "/etc/tireless/config.toml", global = true)]
config: String,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Manage the polled repo list.
Repo {
#[command(subcommand)]
action: RepoAction,
},
/// Inspect and manipulate jobs.
Job {
#[command(subcommand)]
action: JobAction,
},
/// Show current lane state: in flight, window budget, provider signals.
Lanes,
/// Verify configuration and credentials without starting a service.
///
/// Checks that the Claude Code credential store exists, reports which
/// billing mode a run would use, and asserts the OpenCode lane is not
/// pointed at Anthropic.
Preflight,
}
#[derive(Subcommand, Debug)]
enum RepoAction {
List,
Add {
/// `gitea` or `github`.
#[arg(long, default_value = "gitea")]
forge: String,
/// `owner/repo`.
slug: String,
/// Seconds between polls; floored by the configured minimum.
#[arg(long, default_value_t = 300)]
interval: u32,
},
Remove {
slug: String,
},
/// Pause or resume polling without forgetting the repo's configuration.
Enable {
slug: String,
#[arg(long)]
off: bool,
},
}
#[derive(Subcommand, Debug)]
enum JobAction {
List {
#[arg(long)]
state: Option<String>,
},
Show {
id: String,
},
/// Return a job to the pool, clearing its claim.
Release {
id: String,
},
/// Run one job now, bypassing the poll interval but *not* the governor.
Run {
id: String,
/// Print the prompt and the plan of action without spawning an agent.
#[arg(long)]
dry_run: bool,
},
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse();
// Subcommand handlers land alongside the stages that give them something to
// talk to; see doc/plan/design.md §7.
println!("{:?} (config: {})", args.command, args.config);
Ok(())
}

View File

@@ -0,0 +1,19 @@
[package]
name = "tireless-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Business logic for tireless: routing, budgets, scheduling, job lifecycle."
[dependencies]
tireless-entities = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,270 @@
//! Staying inside provider limits.
//!
//! tireless runs unattended, so nothing stops it asking for work except the
//! rules encoded here. Three independent brakes, any of which can hold a lane:
//!
//! 1. **Concurrency** — how many runs of a lane may be in flight at once. The
//! Claude Code lane defaults to 1: a subscription is one person's allowance,
//! and parallel sessions are the fastest way to exhaust it.
//! 2. **Window budget** — a hard ceiling on runs started per rolling window.
//! Not advisory: when it is spent the lane stops until the window rolls.
//! 3. **Provider signal** — a rate-limit event reported by the agent itself.
//! This is authoritative and overrides optimism on our side.
//!
//! On the third: Claude Code emits `rate_limit_event` messages in its stream.
//! vibe-kanban parses them and then discards them (the match arm at
//! `crates/executors/src/executors/claude.rs:1954` is empty). tireless consumes
//! them instead — for an unattended driver they are the single most useful
//! signal available, because they say what the provider thinks, not what we
//! guessed.
use chrono::{DateTime, Duration, Utc};
use tireless_entities::AgentKind;
/// Per-lane limits, from config.
#[derive(Debug, Clone)]
pub struct LaneBudget {
pub lane: AgentKind,
/// Maximum concurrent runs. Claude Code defaults to 1.
pub max_concurrent: u32,
/// Maximum runs started per window.
pub max_runs_per_window: u32,
pub window: Duration,
}
impl LaneBudget {
/// Conservative defaults. Deliberately low: it is easy to raise a ceiling
/// after watching real usage, and unpleasant to discover you burned a
/// month's allowance overnight.
pub fn conservative(lane: AgentKind) -> Self {
match lane {
AgentKind::ClaudeCode => Self {
lane,
max_concurrent: 1,
max_runs_per_window: 12,
window: Duration::hours(5),
},
// Local inference: the constraint is the GPU fleet, not a bill.
AgentKind::Opencode => Self {
lane,
max_concurrent: 2,
max_runs_per_window: 240,
window: Duration::hours(5),
},
}
}
}
/// A limit reported by the provider, parsed from an agent's own output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LimitSignal {
/// When the provider says the limit resets, if it said.
pub resets_at: Option<DateTime<Utc>>,
/// Whether the limit is currently being enforced (as opposed to a warning
/// that one is approaching).
pub exhausted: bool,
}
/// Whether a lane may start another run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Go,
/// Hold until the given instant, then re-ask.
Hold {
until: DateTime<Utc>,
reason: &'static str,
},
/// Hold indefinitely; an operator must intervene.
Stop {
reason: &'static str,
},
}
/// Current observed state of a lane.
#[derive(Debug, Clone, Default)]
pub struct LaneState {
pub in_flight: u32,
/// Run start times inside the current window.
pub recent_starts: Vec<DateTime<Utc>>,
/// Most recent provider signal, if any.
pub last_signal: Option<LimitSignal>,
/// Consecutive failures; trips the circuit breaker.
pub consecutive_failures: u32,
}
/// Decides whether a lane may start work.
#[derive(Debug, Clone)]
pub struct Governor {
pub budget: LaneBudget,
/// Consecutive failures before the lane stops asking for work.
pub failure_threshold: u32,
}
impl Governor {
pub fn new(budget: LaneBudget) -> Self {
Self {
budget,
failure_threshold: 3,
}
}
/// May this lane start another run right now?
///
/// Checks run cheapest-and-most-authoritative first: a provider that has
/// told us it is out of capacity is not worth second-guessing.
pub fn admit(&self, state: &LaneState, now: DateTime<Utc>) -> Verdict {
// 1. The provider's own word.
if let Some(sig) = &state.last_signal
&& sig.exhausted
{
return match sig.resets_at {
Some(until) if until > now => Verdict::Hold {
until,
reason: "provider reported limit exhausted",
},
// Exhausted with no reset time: back off a window rather than spin.
Some(_) | None => Verdict::Hold {
until: now + self.budget.window,
reason: "provider reported limit, no reset time given",
},
};
}
// 2. Circuit breaker — repeated failure means something is wrong that
// retrying will not fix, and each retry still costs tokens.
if state.consecutive_failures >= self.failure_threshold {
return Verdict::Stop {
reason: "consecutive failures exceeded threshold",
};
}
// 3. Concurrency.
if state.in_flight >= self.budget.max_concurrent {
return Verdict::Hold {
until: now + Duration::seconds(30),
reason: "lane at concurrency limit",
};
}
// 4. Window budget.
let cutoff = now - self.budget.window;
let started_in_window = state.recent_starts.iter().filter(|t| **t > cutoff).count() as u32;
if started_in_window >= self.budget.max_runs_per_window {
let oldest = state
.recent_starts
.iter()
.filter(|t| **t > cutoff)
.min()
.copied();
let until = oldest
.map(|t| t + self.budget.window)
.unwrap_or(now + self.budget.window);
return Verdict::Hold {
until,
reason: "window run budget spent",
};
}
Verdict::Go
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gov() -> Governor {
Governor::new(LaneBudget::conservative(AgentKind::ClaudeCode))
}
#[test]
fn idle_lane_is_admitted() {
let now = Utc::now();
assert_eq!(gov().admit(&LaneState::default(), now), Verdict::Go);
}
#[test]
fn concurrency_limit_holds_the_lane() {
let now = Utc::now();
let state = LaneState {
in_flight: 1,
..Default::default()
};
assert!(matches!(gov().admit(&state, now), Verdict::Hold { .. }));
}
#[test]
fn provider_signal_beats_available_budget() {
let now = Utc::now();
let resets = now + Duration::hours(2);
// Lane is otherwise completely idle and within budget.
let state = LaneState {
last_signal: Some(LimitSignal {
resets_at: Some(resets),
exhausted: true,
}),
..Default::default()
};
match gov().admit(&state, now) {
Verdict::Hold { until, .. } => assert_eq!(until, resets),
other => panic!("expected hold, got {other:?}"),
}
}
#[test]
fn exhausted_signal_without_reset_backs_off_a_window() {
let now = Utc::now();
let state = LaneState {
last_signal: Some(LimitSignal {
resets_at: None,
exhausted: true,
}),
..Default::default()
};
match gov().admit(&state, now) {
Verdict::Hold { until, .. } => assert_eq!(until, now + Duration::hours(5)),
other => panic!("expected hold, got {other:?}"),
}
}
#[test]
fn spent_window_budget_holds_until_oldest_start_ages_out() {
let now = Utc::now();
let g = gov();
let oldest = now - Duration::hours(1);
let mut recent_starts = vec![oldest];
recent_starts.extend((1..12).map(|i| now - Duration::minutes(i)));
let state = LaneState {
recent_starts,
..Default::default()
};
match g.admit(&state, now) {
Verdict::Hold { until, .. } => assert_eq!(until, oldest + g.budget.window),
other => panic!("expected hold, got {other:?}"),
}
}
#[test]
fn starts_outside_the_window_do_not_count() {
let now = Utc::now();
// Twelve starts, all older than the 5h window.
let state = LaneState {
recent_starts: (1..=12)
.map(|i| now - Duration::hours(5) - Duration::minutes(i))
.collect(),
..Default::default()
};
assert_eq!(gov().admit(&state, now), Verdict::Go);
}
#[test]
fn repeated_failure_stops_the_lane() {
let now = Utc::now();
let state = LaneState {
consecutive_failures: 3,
..Default::default()
};
assert!(matches!(gov().admit(&state, now), Verdict::Stop { .. }));
}
}

View File

@@ -0,0 +1,12 @@
//! Business logic for tireless.
//!
//! Everything here is pure where it can be. I/O is expressed as traits in
//! [`port`], implemented by `tireless-data` (forge, persistence) and
//! `tireless-agent` (process orchestration).
pub mod budget;
pub mod port;
pub mod routing;
pub use budget::{Governor, LaneBudget, LimitSignal, Verdict};
pub use routing::{RouteDecision, route};

View File

@@ -0,0 +1,96 @@
//! Ports: the interfaces core needs from the outside world.
//!
//! Adapters live in `tireless-data` (forge clients, Postgres) and
//! `tireless-agent` (Claude Code, OpenCode). Core depends on none of them.
use async_trait::async_trait;
use tireless_entities::{AgentKind, Error, IssueRef, Job, JobState, PullRequestRef, TrackedRepo};
use uuid::Uuid;
/// An issue as seen on a forge, before tireless has interpreted it.
#[derive(Debug, Clone)]
pub struct DiscoveredIssue {
pub issue: IssueRef,
pub title: String,
pub body: String,
pub labels: Vec<String>,
pub is_open: bool,
}
/// Read and write access to a forge (Gitea or GitHub).
#[async_trait]
pub trait ForgeClient: Send + Sync {
/// List open issues carrying the opt-in label. Implementations should send a
/// conditional request when `repo.last_etag` is set, and return an empty
/// vec on `304 Not Modified`.
async fn list_opted_in_issues(&self, repo: &TrackedRepo)
-> Result<Vec<DiscoveredIssue>, Error>;
async fn add_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>;
async fn remove_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>;
async fn comment(&self, issue: &IssueRef, body: &str) -> Result<(), Error>;
/// Create a child issue during a Plan job.
async fn create_issue(
&self,
repo: &TrackedRepo,
title: &str,
body: &str,
labels: &[String],
) -> Result<IssueRef, Error>;
/// Open a pull request from `head` into the repo's default branch.
async fn open_pull_request(
&self,
repo: &TrackedRepo,
head: &str,
title: &str,
body: &str,
) -> Result<PullRequestRef, Error>;
}
/// Persistence for repos, jobs and runs.
#[async_trait]
pub trait JobStore: Send + Sync {
async fn tracked_repos(&self) -> Result<Vec<TrackedRepo>, Error>;
/// Insert discovered issues as `Pending` jobs, ignoring any that already
/// have a non-terminal job. Returns the number newly enqueued.
async fn enqueue(&self, issues: &[DiscoveredIssue]) -> Result<usize, Error>;
/// Atomically claim one eligible job for `worker`, using
/// `FOR UPDATE SKIP LOCKED`. Returns `None` when the queue is empty or every
/// candidate is gated by a lane cap.
async fn claim_next(
&self,
worker: &str,
allowed_lanes: &[AgentKind],
) -> Result<Option<Job>, Error>;
async fn transition(&self, job: Uuid, to: JobState) -> Result<(), Error>;
/// Return claims whose lease expired back to `Pending`. Called on a timer so
/// a worker that died mid-job does not strand its issue.
async fn expire_stale_claims(&self) -> Result<usize, Error>;
}
/// Drives one coding agent against a prepared checkout.
#[async_trait]
pub trait AgentExecutor: Send + Sync {
fn kind(&self) -> AgentKind;
/// Run the agent to completion in `workdir`, streaming events to the store.
async fn run(&self, workdir: &std::path::Path, prompt: &str) -> Result<ExecutionReport, Error>;
}
/// What an executor reports back after a run.
#[derive(Debug, Clone, Default)]
pub struct ExecutionReport {
pub session_id: Option<String>,
pub model: Option<String>,
/// Populated from Claude Code's `apiKeySource`; `None` for OpenCode.
pub api_key_source: Option<String>,
/// Raw `rate_limit_event` payloads observed during the run, in order.
pub limit_signals: Vec<serde_json::Value>,
pub exit_success: bool,
}

View File

@@ -0,0 +1,127 @@
//! Which agent handles which job.
//!
//! The rule, in one sentence: **Claude Code gets judgement, OpenCode gets
//! specification.** Planning is always Claude Code because decomposition is the
//! high-judgement, low-volume task. Implementation goes to OpenCode when a
//! tireless plan already specified the work, and to Claude Code when it did not.
//!
//! An explicit label always wins, so an operator can override any inference.
use tireless_entities::{AgentKind, Job, JobKind, LabelProtocol};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteDecision {
pub agent: AgentKind,
/// Why this lane was chosen. Recorded on the run and shown in the dashboard
/// so a surprising route is diagnosable without re-reading the code.
pub reason: &'static str,
}
/// Choose a lane for `job`, given the labels currently on its issue.
pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDecision {
let has = |l: &str| labels.iter().any(|x| x == l);
// An explicit operator override beats every inference below.
if has(&protocol.force_cc) {
return RouteDecision {
agent: AgentKind::ClaudeCode,
reason: "forced by label",
};
}
if has(&protocol.force_oc) {
return RouteDecision {
agent: AgentKind::Opencode,
reason: "forced by label",
};
}
match job.kind {
// Decomposition is judgement work and low volume: always the strong model.
JobKind::Plan => RouteDecision {
agent: AgentKind::ClaudeCode,
reason: "planning is always cc",
},
// A job descended from a tireless plan has a machine-written spec to work
// from, which is what OpenCode against a local model is good at.
JobKind::Implement if job.parent_job_id.is_some() => RouteDecision {
agent: AgentKind::Opencode,
reason: "implements a tireless plan",
},
// A human-written issue with no plan behind it needs interpretation.
JobKind::Implement => RouteDecision {
agent: AgentKind::ClaudeCode,
reason: "unplanned issue needs judgement",
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use tireless_entities::{Forge, IssueRef, JobState};
use uuid::Uuid;
fn job(kind: JobKind, parent: Option<Uuid>) -> Job {
Job {
id: Uuid::new_v4(),
issue: IssueRef {
forge: Forge::Gitea,
owner: "lair".into(),
repo: "tireless".into(),
number: 1,
},
kind,
state: JobState::Pending,
claimed_by: None,
claim_expires_at: None,
parent_job_id: parent,
attempts: 0,
last_error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn planning_always_goes_to_claude_code() {
let p = LabelProtocol::default();
let d = route(&job(JobKind::Plan, None), &[], &p);
assert_eq!(d.agent, AgentKind::ClaudeCode);
}
#[test]
fn planned_implementation_goes_to_opencode() {
let p = LabelProtocol::default();
let d = route(&job(JobKind::Implement, Some(Uuid::new_v4())), &[], &p);
assert_eq!(d.agent, AgentKind::Opencode);
}
#[test]
fn unplanned_implementation_goes_to_claude_code() {
let p = LabelProtocol::default();
let d = route(&job(JobKind::Implement, None), &[], &p);
assert_eq!(d.agent, AgentKind::ClaudeCode);
}
#[test]
fn label_override_beats_inference() {
let p = LabelProtocol::default();
// Would otherwise be cc.
let d = route(
&job(JobKind::Plan, None),
std::slice::from_ref(&p.force_oc),
&p,
);
assert_eq!(d.agent, AgentKind::Opencode);
// Would otherwise be oc.
let d = route(
&job(JobKind::Implement, Some(Uuid::new_v4())),
std::slice::from_ref(&p.force_cc),
&p,
);
assert_eq!(d.agent, AgentKind::ClaudeCode);
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "tireless-data"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Adapters for tireless: Postgres persistence and forge (Gitea/GitHub) clients."
[dependencies]
tireless-entities = { workspace = true }
tireless-core = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,32 @@
//! Forge clients.
//!
//! Gitea is the default (`architecture/generic.md` §11); GitHub is supported for
//! the legacy repos still being migrated.
//!
//! Both clients are expected to be polite API citizens: conditional requests
//! using the stored `ETag`, a floor on poll interval, and backoff with jitter on
//! `429`/`5xx`. Being unattended is not a licence to hammer a forge.
use reqwest::Client;
use url::Url;
pub struct GiteaClient {
#[allow(dead_code)]
http: Client,
#[allow(dead_code)]
base: Url,
/// Token belonging to the dedicated `tireless` bot account — never the
/// operator's. Scoped to the repos it collaborates on, and unable to push to
/// a protected default branch.
#[allow(dead_code)]
token: String,
}
pub struct GitHubClient {
#[allow(dead_code)]
http: Client,
#[allow(dead_code)]
token: String,
}
// `ForgeClient` impls land in stage 1. See doc/plan/design.md §7.

View File

@@ -0,0 +1,48 @@
//! Adapters: everything that talks to Postgres or to a forge.
//!
//! Implements the ports declared in `tireless_core::port`. Nothing here holds
//! business logic — routing and budget decisions belong in core.
pub mod forge;
pub mod store;
pub use forge::{GitHubClient, GiteaClient};
pub use store::PgStore;
/// Connection settings for the house Postgres cluster.
///
/// Per `architecture/generic.md` §5 the connection is **mTLS with passwordless
/// auth** — the host's own certificate identifies the client and `pg_ident.conf`
/// maps its CN to a role. There is deliberately no password field here; if one
/// ever seems necessary, that is a signal to revisit the ident mapping instead.
#[derive(Debug, Clone)]
pub struct PgConfig {
pub host: String,
pub port: u16,
pub database: String,
pub user: String,
/// Host certificate, world-readable at the standard path.
pub client_cert: std::path::PathBuf,
/// Host key; the service account is granted read via `setfacl` at deploy.
pub client_key: std::path::PathBuf,
pub root_cert: std::path::PathBuf,
}
impl PgConfig {
/// Build a libpq-style URL. Certs rotate every 24h (§11), so callers must be
/// able to re-establish connections rather than assume a stable pool.
pub fn connection_url(&self) -> String {
format!(
"postgres://{user}@{host}:{port}/{db}\
?sslmode=verify-full\
&sslcert={cert}&sslkey={key}&sslrootcert={root}",
user = self.user,
host = self.host,
port = self.port,
db = self.database,
cert = self.client_cert.display(),
key = self.client_key.display(),
root = self.root_cert.display(),
)
}
}

View File

@@ -0,0 +1,22 @@
//! Postgres persistence.
//!
//! Work-claiming uses `SELECT … FOR UPDATE SKIP LOCKED` per
//! `architecture/generic.md` §3, which makes the claim atomic across however
//! many workers are running. Forge labels mirror this state for humans but are
//! never consulted to decide whether a job is already taken.
use sqlx::PgPool;
pub struct PgStore {
#[allow(dead_code)]
pool: PgPool,
}
impl PgStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
// The `JobStore` impl lands in stage 1; the migrations that back it live in
// `crates/tireless-data/migrations/`. See doc/plan/design.md §7.

View File

@@ -0,0 +1,17 @@
[package]
name = "tireless-entities"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Domain types, DTOs and error enums for tireless. No I/O."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
ts-rs = { workspace = true }

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Errors that can cross a crate boundary within tireless.
#[derive(Debug, Error)]
pub enum Error {
#[error("configuration is invalid: {0}")]
Config(String),
/// Raised when the OpenCode lane is pointed at an Anthropic provider.
///
/// OpenCode is a third-party harness; driving an Anthropic *subscription*
/// through it is the pattern Anthropic blocks. tireless refuses to start
/// rather than let a config edit walk into it. See `doc/plan/design.md` §3.
#[error(
"opencode provider {0:?} is an Anthropic provider; tireless routes Anthropic \
work through the Claude Code lane only"
)]
AnthropicViaOpencode(String),
#[error("forge request failed: {0}")]
Forge(String),
#[error("agent run failed: {0}")]
Agent(String),
#[error("budget exhausted for lane {lane}: {detail}")]
BudgetExhausted { lane: String, detail: String },
#[error("job {0} is not in a state that permits this transition")]
IllegalTransition(uuid::Uuid),
}

View File

@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// A source forge tireless can poll and push to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum Forge {
/// Self-hosted Gitea at `git.lair.cafe` / `git.internal`. The default.
Gitea,
/// Legacy repos still on GitHub, per `architecture/generic.md` §11.
GitHub,
}
/// Stable coordinates for an issue on some forge.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct IssueRef {
pub forge: Forge,
pub owner: String,
pub repo: String,
pub number: i64,
}
impl IssueRef {
pub fn slug(&self) -> String {
format!("{}/{}#{}", self.owner, self.repo, self.number)
}
}
/// Coordinates for a pull request tireless opened.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PullRequestRef {
pub forge: Forge,
pub owner: String,
pub repo: String,
pub number: i64,
pub head_branch: String,
pub url: String,
}

View File

@@ -0,0 +1,74 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::forge::IssueRef;
/// What tireless was asked to do with an issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum JobKind {
/// Decompose the issue into an epic and child issues. Produces issues, not code.
Plan,
/// Implement the issue and open a pull request.
Implement,
}
/// Job lifecycle.
///
/// A job is the unit of work-claiming. Claiming is a Postgres row transition
/// under `FOR UPDATE SKIP LOCKED` (`architecture/generic.md` §3), not a forge
/// label — labels cannot be claimed atomically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum JobState {
/// Discovered by the poller, eligible to be claimed.
Pending,
/// Held by a worker. `claimed_by` and `claim_expires_at` are set.
Claimed,
/// An agent is running against a clone.
Running,
/// Delivered: children created (Plan) or PR opened (Implement).
Delivered,
/// Needs a human. Terminal until an operator intervenes.
Blocked,
/// Failed past the retry budget. Terminal.
Failed,
/// Withdrawn — the opt-in label was removed, or the issue closed, mid-flight.
Abandoned,
}
impl JobState {
/// Terminal states are never re-claimed by a worker.
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Delivered | Self::Blocked | Self::Failed | Self::Abandoned
)
}
}
/// One unit of work against one issue.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Job {
pub id: Uuid,
pub issue: IssueRef,
pub kind: JobKind,
pub state: JobState,
/// Worker identity holding the claim, if any.
pub claimed_by: Option<String>,
/// Claims expire so a crashed worker's jobs return to the pool.
pub claim_expires_at: Option<DateTime<Utc>>,
/// Set when this job's issue was itself produced by a Plan job. Presence of
/// a parent is the primary signal to route implementation to OpenCode —
/// the plan is the spec (see `doc/plan/design.md` §3).
pub parent_job_id: Option<Uuid>,
pub attempts: i32,
pub last_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// The label vocabulary tireless reads from and writes to a forge.
///
/// Labels are the *human-facing* interface: they are how an operator opts an
/// issue in, and how tireless reports back. They are **not** the authority on
/// state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
/// mirror, reconciled on every poll.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct LabelProtocol {
/// Opt-in marker. Without it tireless never touches an issue, regardless of
/// any other label present. Default: `tireless`.
pub opt_in: String,
/// Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
pub mode_plan: String,
/// Requests an implementation and a pull request. Default: `tireless/implement`.
pub mode_implement: String,
/// Forces the Claude Code lane. Default: `tireless/agent:cc`.
pub force_cc: String,
/// Forces the OpenCode lane. Default: `tireless/agent:oc`.
pub force_oc: String,
/// Written by tireless while a job holds the issue. Default: `tireless/claimed`.
pub state_claimed: String,
/// Written by tireless when it needs a human. Default: `tireless/blocked`.
pub state_blocked: String,
/// Written by tireless when it has delivered. Default: `tireless/done`.
pub state_done: String,
}
impl Default for LabelProtocol {
fn default() -> Self {
Self {
opt_in: "tireless".into(),
mode_plan: "tireless/plan".into(),
mode_implement: "tireless/implement".into(),
force_cc: "tireless/agent:cc".into(),
force_oc: "tireless/agent:oc".into(),
state_claimed: "tireless/claimed".into(),
state_blocked: "tireless/blocked".into(),
state_done: "tireless/done".into(),
}
}
}

View File

@@ -0,0 +1,19 @@
//! Domain types for tireless.
//!
//! This crate is deliberately I/O-free: it is depended on by every other crate
//! in the workspace (including, via `ts-rs` bindings, the dashboard), so it must
//! stay cheap to compile and free of runtime opinions.
pub mod error;
pub mod forge;
pub mod job;
pub mod label;
pub mod repo;
pub mod run;
pub use error::Error;
pub use forge::{Forge, IssueRef, PullRequestRef};
pub use job::{Job, JobKind, JobState};
pub use label::LabelProtocol;
pub use repo::{PollSchedule, TrackedRepo};
pub use run::{AgentKind, AgentRun, BillingMode, RunOutcome};

View File

@@ -0,0 +1,54 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::forge::Forge;
/// How often a repo's issues are polled.
///
/// The floor exists to keep tireless a well-behaved API client: a repo that is
/// quiet for days does not need to be asked every thirty seconds. See
/// `doc/plan/design.md` §5.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PollSchedule {
/// Seconds between polls. Clamped to `>= min_interval_seconds` by core.
pub interval_seconds: u32,
/// Optional quiet window (local time, `HH:MM`), during which no poll runs
/// and no job is claimed. Both ends required if either is set.
pub quiet_from: Option<String>,
pub quiet_until: Option<String>,
/// When false the repo stays configured but is skipped entirely.
pub enabled: bool,
}
impl Default for PollSchedule {
fn default() -> Self {
Self {
interval_seconds: 300,
quiet_from: None,
quiet_until: None,
enabled: true,
}
}
}
/// A repo tireless watches for labelled issues.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TrackedRepo {
pub id: Uuid,
pub forge: Forge,
pub owner: String,
pub repo: String,
/// Clone URL used for the mirror cache. SSH for Gitea, HTTPS for GitHub.
pub clone_url: String,
/// Branch PRs target. Usually `main`; tireless never pushes to it directly.
pub default_branch: String,
pub schedule: PollSchedule,
/// Set from the forge's `ETag` so repeat polls are conditional requests.
pub last_etag: Option<String>,
pub last_polled_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,90 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::forge::PullRequestRef;
/// Which coding agent executed a run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum AgentKind {
/// Anthropic's first-party CLI, spawned as a subprocess. Authenticates with
/// the service user's own subscription unless `ANTHROPIC_API_KEY` is present
/// in the unit environment.
ClaudeCode,
/// OpenCode, run as a loopback server against an OpenAI-compatible backend
/// (helexa cortex). Never Anthropic — enforced at startup.
Opencode,
}
impl AgentKind {
/// Lane name used for concurrency caps, budgets and circuit breakers.
pub fn lane(self) -> &'static str {
match self {
Self::ClaudeCode => "cc",
Self::Opencode => "oc",
}
}
}
/// Where a Claude Code run's tokens were billed.
///
/// Read from the `apiKeySource` field of Claude Code's `system`/`init` stream
/// event — the same signal vibe-kanban surfaces in
/// `crates/executors/src/executors/claude.rs:911`. tireless records it per run
/// so the dashboard can show, honestly, which runs drew on the subscription.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum BillingMode {
/// Subscription OAuth (no `ANTHROPIC_API_KEY` in the environment).
Subscription,
/// Pay-as-you-go via an API key supplied in the unit environment.
ApiKey,
/// Not applicable (OpenCode) or not yet determined.
Unknown,
}
/// How a run ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum RunOutcome {
Succeeded,
/// The agent exited non-zero, or produced no usable result.
Failed,
/// Stopped because a provider limit was hit. Retryable after the reset.
RateLimited,
/// Stopped because a tireless budget was exhausted. Retryable next window.
BudgetExhausted,
/// Exceeded the wall-clock ceiling for its lane.
TimedOut,
/// Cancelled by an operator.
Cancelled,
}
/// One invocation of one agent against one job.
///
/// A job may have several runs: a retry after a rate limit, or a follow-up turn
/// resuming the same agent session.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct AgentRun {
pub id: Uuid,
pub job_id: Uuid,
pub agent: AgentKind,
/// Model actually used, as reported by the agent.
pub model: Option<String>,
pub billing: BillingMode,
/// The agent's own session identifier, so a follow-up turn can resume rather
/// than restart. Claude Code reports this in its stream; tireless passes it
/// back via `--resume`.
pub session_id: Option<String>,
pub outcome: Option<RunOutcome>,
/// Pull request opened by this run, if any.
pub pull_request: Option<PullRequestRef>,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
}

View File

@@ -0,0 +1,24 @@
[package]
name = "tireless-worker"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Poller and job runner for tireless. Runs as two systemd units."
[dependencies]
tireless-entities = { workspace = true }
tireless-core = { workspace = true }
tireless-data = { workspace = true }
tireless-agent = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
figment = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,102 @@
//! tireless-worker — polls forges and runs claimed jobs.
//!
//! Ships as one binary with two roles, so a deployment can scale or pause them
//! independently (`tireless-poller.service`, `tireless-runner.service`):
//!
//! - **poll** — discovers opted-in issues and enqueues them. Read-only against
//! the forge except for label mirroring. Cheap, and safe to run always.
//! - **run** — claims a job, prepares a clone, drives an agent, opens a PR.
//! This is the role that spends tokens, and the one the governor gates.
//!
//! Both are idempotent and safe to restart: a crashed runner's claim expires and
//! its job returns to the pool (§3).
use anyhow::Context;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "tireless-worker", version)]
struct Args {
#[arg(long, default_value = "/etc/tireless/config.toml")]
config: String,
#[command(subcommand)]
role: Role,
}
#[derive(Subcommand, Debug)]
enum Role {
/// Discover opted-in issues and enqueue them.
Poll,
/// Claim and execute jobs.
Run {
/// Worker identity recorded on claims. Defaults to the hostname.
#[arg(long, env = "TIRELESS_WORKER_ID")]
id: Option<String>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
let args = Args::parse();
// Fail loudly at startup, not on the first job: an unattended service that
// starts "successfully" and then cannot do anything is worse than one that
// refuses to start.
preflight().context("preflight checks failed")?;
match args.role {
Role::Poll => {
tracing::info!(config = %args.config, "poller starting");
}
Role::Run { id } => {
let id = id.unwrap_or_else(|| {
std::env::var("HOSTNAME").unwrap_or_else(|_| "tireless-runner".into())
});
tracing::info!(config = %args.config, worker = %id, "runner starting");
}
}
shutdown_signal().await;
tracing::info!("tireless-worker stopped cleanly");
Ok(())
}
/// Startup assertions that encode constraints we cannot afford to rediscover at
/// runtime. See doc/plan/design.md §3.
fn preflight() -> anyhow::Result<()> {
// Report which billing mode Claude Code will use, so it is visible in the
// journal from the first line rather than inferred from an invoice later.
let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some();
match tireless_agent::claude::expected_billing(has_key) {
tireless_entities::BillingMode::ApiKey => tracing::warn!(
"ANTHROPIC_API_KEY is set: Claude Code runs will bill pay-as-you-go, \
not the subscription"
),
mode => tracing::info!(?mode, "Claude Code billing mode"),
}
// The OpenCode lane must never be pointed at Anthropic. Read from config in
// stage 5; asserted here so the guard exists from the first commit.
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
if std::env::var_os("JOURNAL_STREAM").is_some() {
fmt().json().with_env_filter(filter).init();
} else {
fmt().with_env_filter(filter).init();
}
}
async fn shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = term.recv() => tracing::info!("SIGTERM received, draining in-flight work"),
_ = int.recv() => tracing::info!("SIGINT received, draining in-flight work"),
}
}

12
dashboard/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tireless</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3079
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
dashboard/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "tireless-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write src"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"prettier": "^3.4.2",
"typescript": "~5.7.2",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.5"
}
}

28
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
import Lanes from './routes/Lanes';
import Jobs from './routes/Jobs';
import Repos from './routes/Repos';
export default function App() {
return (
<div className="app">
<header>
<h1>tireless</h1>
<nav>
<NavLink to="/lanes">lanes</NavLink>
<NavLink to="/jobs">jobs</NavLink>
<NavLink to="/repos">repos</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<Navigate to="/lanes" replace />} />
<Route path="/lanes" element={<Lanes />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/repos" element={<Repos />} />
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Client for tireless-api.
*
* The base URL is stamped at build time by the deploy workflow. In dev, Vite
* proxies /v1 to a local API (see vite.config.ts), so the default empty base
* resolves to a same-origin request in both cases.
*/
const BASE = import.meta.env.VITE_API_BASE_URL ?? '';
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}/v1${path}`, {
...init,
headers: { 'content-type': 'application/json', ...init?.headers },
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return response.status === 204 ? (undefined as T) : await response.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Which coding agent executed a run.
*/
export type AgentKind = "claude_code" | "opencode";

View File

@@ -0,0 +1,27 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentKind } from "./AgentKind";
import type { BillingMode } from "./BillingMode";
import type { PullRequestRef } from "./PullRequestRef";
import type { RunOutcome } from "./RunOutcome";
/**
* One invocation of one agent against one job.
*
* A job may have several runs: a retry after a rate limit, or a follow-up turn
* resuming the same agent session.
*/
export type AgentRun = { id: string, job_id: string, agent: AgentKind,
/**
* Model actually used, as reported by the agent.
*/
model: string | null, billing: BillingMode,
/**
* The agent's own session identifier, so a follow-up turn can resume rather
* than restart. Claude Code reports this in its stream; tireless passes it
* back via `--resume`.
*/
session_id: string | null, outcome: RunOutcome | null,
/**
* Pull request opened by this run, if any.
*/
pull_request: PullRequestRef | null, started_at: string, finished_at: string | null, };

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where a Claude Code run's tokens were billed.
*
* Read from the `apiKeySource` field of Claude Code's `system`/`init` stream
* event — the same signal vibe-kanban surfaces in
* `crates/executors/src/executors/claude.rs:911`. tireless records it per run
* so the dashboard can show, honestly, which runs drew on the subscription.
*/
export type BillingMode = "subscription" | "api_key" | "unknown";

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A source forge tireless can poll and push to.
*/
export type Forge = "gitea" | "git_hub";

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Forge } from "./Forge";
/**
* Stable coordinates for an issue on some forge.
*/
export type IssueRef = { forge: Forge, owner: string, repo: string, number: bigint, };

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { IssueRef } from "./IssueRef";
import type { JobKind } from "./JobKind";
import type { JobState } from "./JobState";
/**
* One unit of work against one issue.
*/
export type Job = { id: string, issue: IssueRef, kind: JobKind, state: JobState,
/**
* Worker identity holding the claim, if any.
*/
claimed_by: string | null,
/**
* Claims expire so a crashed worker's jobs return to the pool.
*/
claim_expires_at: string | null,
/**
* Set when this job's issue was itself produced by a Plan job. Presence of
* a parent is the primary signal to route implementation to OpenCode —
* the plan is the spec (see `doc/plan/design.md` §3).
*/
parent_job_id: string | null, attempts: number, last_error: string | null, created_at: string, updated_at: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* What tireless was asked to do with an issue.
*/
export type JobKind = "plan" | "implement";

View File

@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Job lifecycle.
*
* A job is the unit of work-claiming. Claiming is a Postgres row transition
* under `FOR UPDATE SKIP LOCKED` (`architecture/generic.md` §3), not a forge
* label — labels cannot be claimed atomically.
*/
export type JobState = "pending" | "claimed" | "running" | "delivered" | "blocked" | "failed" | "abandoned";

View File

@@ -0,0 +1,44 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The label vocabulary tireless reads from and writes to a forge.
*
* Labels are the *human-facing* interface: they are how an operator opts an
* issue in, and how tireless reports back. They are **not** the authority on
* state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
* mirror, reconciled on every poll.
*/
export type LabelProtocol = {
/**
* Opt-in marker. Without it tireless never touches an issue, regardless of
* any other label present. Default: `tireless`.
*/
opt_in: string,
/**
* Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
*/
mode_plan: string,
/**
* Requests an implementation and a pull request. Default: `tireless/implement`.
*/
mode_implement: string,
/**
* Forces the Claude Code lane. Default: `tireless/agent:cc`.
*/
force_cc: string,
/**
* Forces the OpenCode lane. Default: `tireless/agent:oc`.
*/
force_oc: string,
/**
* Written by tireless while a job holds the issue. Default: `tireless/claimed`.
*/
state_claimed: string,
/**
* Written by tireless when it needs a human. Default: `tireless/blocked`.
*/
state_blocked: string,
/**
* Written by tireless when it has delivered. Default: `tireless/done`.
*/
state_done: string, };

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How often a repo's issues are polled.
*
* The floor exists to keep tireless a well-behaved API client: a repo that is
* quiet for days does not need to be asked every thirty seconds. See
* `doc/plan/design.md` §5.
*/
export type PollSchedule = {
/**
* Seconds between polls. Clamped to `>= min_interval_seconds` by core.
*/
interval_seconds: number,
/**
* Optional quiet window (local time, `HH:MM`), during which no poll runs
* and no job is claimed. Both ends required if either is set.
*/
quiet_from: string | null, quiet_until: string | null,
/**
* When false the repo stays configured but is skipped entirely.
*/
enabled: boolean, };

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Forge } from "./Forge";
/**
* Coordinates for a pull request tireless opened.
*/
export type PullRequestRef = { forge: Forge, owner: string, repo: string, number: bigint, head_branch: string, url: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How a run ended.
*/
export type RunOutcome = "succeeded" | "failed" | "rate_limited" | "budget_exhausted" | "timed_out" | "cancelled";

View File

@@ -0,0 +1,20 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Forge } from "./Forge";
import type { PollSchedule } from "./PollSchedule";
/**
* A repo tireless watches for labelled issues.
*/
export type TrackedRepo = { id: string, forge: Forge, owner: string, repo: string,
/**
* Clone URL used for the mirror cache. SSH for Gitea, HTTPS for GitHub.
*/
clone_url: string,
/**
* Branch PRs target. Usually `main`; tireless never pushes to it directly.
*/
default_branch: string, schedule: PollSchedule,
/**
* Set from the forge's `ETag` so repeat polls are conditional requests.
*/
last_etag: string | null, last_polled_at: string | null, created_at: string, };

51
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,51 @@
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, sans-serif;
line-height: 1.5;
}
body {
margin: 0;
}
.app {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem;
}
.app > header {
display: flex;
align-items: baseline;
gap: 1.5rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
padding-bottom: 0.75rem;
margin-bottom: 1.5rem;
}
.app > header h1 {
font-size: 1.25rem;
margin: 0;
letter-spacing: -0.01em;
}
nav {
display: flex;
gap: 1rem;
}
nav a {
color: inherit;
text-decoration: none;
opacity: 0.6;
}
nav a.active {
opacity: 1;
font-weight: 600;
}
.placeholder {
opacity: 0.6;
font-style: italic;
}

28
dashboard/src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
// Server state lives in React Query; the dashboard is a rendering and
// interaction layer only (architecture/generic.md §4).
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
refetchOnWindowFocus: true,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,12 @@
/**
* Job list and detail: what tireless has claimed, what it is running, what it
* delivered, and what is blocked waiting on a human.
*/
export default function Jobs() {
return (
<section>
<h2>jobs</h2>
<p className="placeholder">Job listing arrives with stage 2.</p>
</section>
);
}

View File

@@ -0,0 +1,18 @@
/**
* Lane status: the page that answers "is tireless about to spend my
* subscription, and how much is left?".
*
* Shows, per lane: in-flight runs, window budget consumed, the most recent
* provider limit signal, and whether the circuit breaker has tripped. Stage 6
* adds the pause/resume controls; until then this is read-only.
*/
export default function Lanes() {
return (
<section>
<h2>lanes</h2>
<p className="placeholder">
Lane telemetry arrives with stage 3 (Claude Code) and stage 5 (OpenCode).
</p>
</section>
);
}

View File

@@ -0,0 +1,15 @@
/**
* Polled repo management: add and remove Gitea/GitHub repos, and edit each
* one's poll schedule and quiet window.
*
* This is the primary reason the dashboard exists — the poller's schedule is
* meant to be changed without a redeploy.
*/
export default function Repos() {
return (
<section>
<h2>repos</h2>
<p className="placeholder">Repo CRUD arrives with stage 1.</p>
</section>
);
}

10
dashboard/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Stamped at build time by the deploy workflow. */
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

23
dashboard/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated/AgentKind.ts","./src/api/generated/AgentRun.ts","./src/api/generated/BillingMode.ts","./src/api/generated/Forge.ts","./src/api/generated/IssueRef.ts","./src/api/generated/Job.ts","./src/api/generated/JobKind.ts","./src/api/generated/JobState.ts","./src/api/generated/LabelProtocol.ts","./src/api/generated/PollSchedule.ts","./src/api/generated/PullRequestRef.ts","./src/api/generated/RunOutcome.ts","./src/api/generated/TrackedRepo.ts","./src/routes/Jobs.tsx","./src/routes/Lanes.tsx","./src/routes/Repos.tsx"],"version":"5.7.3"}

23
dashboard/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// Build output is static and served by nginx from /var/www/tireless — no Node
// in production (architecture/generic.md §4). The API base URL is stamped at
// build time from VITE_API_BASE_URL by the deploy workflow.
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: true,
},
server: {
port: 5173,
proxy: {
// Dev-only: point at a local tireless-api on its registered port.
'/v1': {
target: 'http://127.0.0.1:23296',
changeOrigin: true,
},
},
},
});

480
doc/plan/design.md Normal file
View File

@@ -0,0 +1,480 @@
# tireless — design and staged implementation plan
**Status:** planning. Nothing in stages 1+ is built yet.
**Conventions:** [`~/git/architecture`](https://git.lair.cafe/lair/architecture) —
`generic.md` is the baseline; deviations are flagged inline below and in `readme.md`.
---
## 1. What tireless is
A daemon that watches labelled issues on Gitea (and, for legacy repos, GitHub),
claims them, and either **plans** them (decomposing an issue into an epic and
child issues) or **implements** them (producing a branch and a pull request).
A human reviews the output. Nothing merges itself.
The point is to move the operator's attention up a level: from writing
implementation plans and implementations, to identifying what should be worked
on and reviewing what came back.
### What tireless is not
- **Not a kanban board.** vibe-kanban is the reference implementation for
*driving agents*; its task/project/board model is deliberately absent here.
The forge's issues are the work list.
- **Not a merge robot.** It opens PRs. Review and merge stay human.
- **Not a model provider client.** It never speaks to Anthropic or any inference
API directly. This is load-bearing — see §3.
- **Not a multi-tenant service.** One operator, one subscription, one fleet.
Sharing it with others would breach the Anthropic consumer terms (§3.4).
---
## 2. Operating model
### 2.1 The loop
```
poll ──▶ enqueue ──▶ claim ──▶ prepare clone ──▶ run agent ──▶ deliver ──▶ report
▲ │
└──────────────────────────── reconcile ◀──────────────────────────────────┘
```
Two systemd units, one binary:
| Unit | Role | Spends tokens? |
| --- | --- | --- |
| `tireless-poller` | discovers opted-in issues, enqueues, mirrors labels | no |
| `tireless-runner` | claims jobs, prepares clones, drives agents, opens PRs | **yes** |
Splitting them means the discovery loop can run continuously while the
token-spending half is paused, throttled, or restarted independently. During an
incident the useful action is almost always "stop the runner, leave the poller
running" — which is a `systemctl stop` rather than a config change.
### 2.2 The label protocol
Labels are the human interface. An operator opts an issue in by labelling it;
tireless reports back the same way.
| Label | Written by | Meaning |
| --- | --- | --- |
| `tireless` | human | **Opt-in.** Without it tireless ignores the issue entirely, whatever else is present. |
| `tireless/plan` | human | Decompose into an epic and child issues. |
| `tireless/implement` | human | Implement and open a PR. |
| `tireless/agent:cc` | human | Force the Claude Code lane. |
| `tireless/agent:oc` | human | Force the OpenCode lane. |
| `tireless/claimed` | tireless | A job holds this issue. |
| `tireless/blocked` | tireless | Needs a human; tireless has stopped. |
| `tireless/done` | tireless | Delivered — children created, or PR opened. |
The opt-in label is separate from the mode labels on purpose. Removing one label
(`tireless`) disables the issue without destroying the operator's expressed
intent about *how* it should be handled, and a single unlabelled repo full of
`tireless/implement` leftovers cannot accidentally activate.
**Labels are not the source of truth.** They are a best-effort mirror of state
held in Postgres, reconciled on every poll. Two reasons: a label edit is not
atomic, so it cannot safely express a claim; and a forge outage must not lose
job state.
### 2.3 Routing: which agent gets the work
Implemented in `tireless-core::routing`, with tests.
| Job | Lane | Why |
| --- | --- | --- |
| `Plan` | Claude Code (Opus) | Decomposition is the high-judgement, low-volume task. Getting a plan wrong is expensive downstream; getting it right is worth the strong model. |
| `Implement`, descended from a tireless plan | OpenCode (helexa) | A tireless plan *is* a spec. Executing a written spec is what a local model on the GPU fleet does well, at no subscription cost. |
| `Implement`, human-written issue | Claude Code | No plan behind it means the issue needs interpretation before it needs code. |
| any, with `tireless/agent:*` | as labelled | An explicit operator override always wins. |
The general rule: **Claude Code gets judgement, OpenCode gets specification.**
This also produces a pleasing economic shape. The subscription is spent on the
scarce thing (planning, and interpreting under-specified work), while the bulk
of mechanical implementation runs on hardware already sitting in the office.
---
## 3. Constraints
These are the reasons the architecture looks the way it does. Each is encoded in
code or config, not merely written down here — comments rot, failing assertions
do not.
### 3.1 Both agents are spawned as vendor binaries
tireless spawns `@anthropic-ai/claude-code` and `opencode-ai` as subprocesses and
lets each authenticate itself. It never constructs a request to a model provider.
This is what makes subscription-backed operation legitimate. Anthropic's
enforced line is credential extraction — taking the subscription OAuth token and
using it in your own API client, which is what got OpenClaw, OpenCode, Roo Code
and Goose blocked in January 2026 (`"This credential is only authorized for use
with Claude Code."`). Running the first-party binary is the permitted side of
that line.
Two invariants follow, and neither may be optimised away:
- **tireless never reads or forwards agent credentials.** It stats
`~/.claude.json` to check a login exists (`tireless-agent::claude::has_credentials`)
and does nothing else with it.
- **tireless never sets `ANTHROPIC_API_KEY`.** The variable reaches Claude Code
only if an operator placed it in the unit environment.
### 3.2 Automated use of a subscription is explicitly permitted
Anthropic's consumer terms §3 prohibit automated access *"Except when you are
accessing our Services via an Anthropic API Key **or where we otherwise
explicitly permit it**"*. The help centre article
[Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)
is that explicit permission, naming three covered categories:
- Claude Agent SDK usage in your own projects
- `claude -p` (non-interactive mode)
- **third-party applications authenticating through your subscription**
The third is tireless. Its current banner: *"We're pausing the changes to Claude
Agent SDK usage described below. For now, nothing has changed: Claude Agent SDK,
`claude -p`, and third-party app usage still draw from your subscription's usage
limits."*
**This is the constraint most likely to change.** The June 15 2026 split into a
separate "Agent SDK credit" pool ($20 Pro / $100 Max 5x / $200 Max 20x) was
paused, not cancelled, with advance notice promised. tireless therefore treats
the auth mode as a **config switch, not an architecture**: dropping
`ANTHROPIC_API_KEY` into `/etc/tireless/tireless.env` moves the whole Claude Code
lane to pay-as-you-go with no code change. Billing mode is recorded per run
(`AgentRun::billing`, read from Claude Code's own `apiKeySource`) so the
dashboard reports what actually happened rather than what was intended.
### 3.3 The OpenCode lane is never Anthropic
OpenCode is a third-party harness with its own provider clients. Driving an
Anthropic subscription through it is precisely the blocked pattern. Anthropic
work goes through the Claude Code lane; OpenCode goes to helexa cortex.
Encoded as a startup assertion — `tireless_agent::opencode::assert_not_anthropic`
— checked against both the provider id and the base URL host, with tests. A
config edit that points the lane at Anthropic fails the service, loudly, at
start. Note that cortex presents an Anthropic-*compatible* API surface; that is
fine and explicitly tested for, because it is local inference with no
subscription involved.
### 3.4 Single operator
The consumer terms §2 forbid sharing account credentials or making the account
available to others. tireless runs as one operator's agent against their own
repos. If a second person's request could trigger a run on this subscription,
that boundary is crossed — which is why the dashboard is mesh-only behind
`tireless.internal` and has no multi-user model.
### 3.5 Concurrency guardrails move
Claude Code capped concurrent subagents at 20 and now defaults nested spawns to
depth 3 (changed twice in July 2026). tireless bounds its own concurrency
(§5) rather than discovering the vendor's limits by hitting them.
---
## 4. State and claiming
### 4.1 Postgres is the authority
House cluster, `magrathea.kosherinata.internal:5432`, mTLS and passwordless
(`generic.md` §5). Role `tireless_rw`, ident-mapped from the deploy host's cert
CN — **installed on both magrathea and frankie**, since a failover to a server
missing the mapping locks tireless out.
### 4.2 Claiming
`SELECT … FOR UPDATE SKIP LOCKED` (`generic.md` §3). The claim is a row
transition, which makes it atomic across any number of runners. Claims carry a
lease (`claim_expires_at`); a timer returns expired claims to the pool so a
runner that died mid-job does not strand its issue.
The forge label `tireless/claimed` is written *after* the database claim
succeeds, and is treated as advisory on read. If a poll finds an issue labelled
claimed with no live job behind it, the label is stale and gets cleaned up —
this is the normal path after a database restore or a hard crash.
### 4.3 Job states
```
Pending ──▶ Claimed ──▶ Running ──┬──▶ Delivered (PR opened / children created)
▲ ├──▶ Blocked (needs a human)
│ └──▶ Failed (past the retry budget)
└───── lease expiry ───────────┘
Abandoned (opt-in removed, or issue closed)
```
`Delivered`, `Blocked`, `Failed` and `Abandoned` are terminal —
`JobState::is_terminal`. A terminal job is never re-claimed; re-running requires
an operator (`tireless job run <id>`) or a fresh label cycle.
### 4.4 Idempotency
Every stage assumes it may be interrupted and re-run:
- enqueue is an upsert keyed on `(forge, owner, repo, number)`;
- a job whose branch already exists on the remote reuses it rather than failing;
- a job whose PR already exists reports it rather than opening a second;
- clone directories are addressed by job id, so a retry cannot collide with a
previous attempt's tree.
---
## 5. Respecting provider limits
Nothing external stops an unattended driver from asking for work. Four brakes,
implemented in `tireless-core::budget` with tests:
1. **Concurrency cap per lane.** Claude Code defaults to **1**. A subscription is
one person's allowance; parallel sessions are the fastest way to exhaust it.
OpenCode defaults to 2, bounded by the GPU fleet rather than a bill.
2. **Window budget.** A hard ceiling on runs started per rolling window
(default 12 per 5h for Claude Code). Not advisory: when spent, the lane stops
until the window rolls. Defaults are deliberately low — raising a ceiling
after watching real usage is easy; discovering you burned a month's allowance
overnight is not.
3. **Provider signal.** Claude Code emits `rate_limit_event` messages in its
stream. vibe-kanban parses them and discards them — the match arm at
`crates/executors/src/executors/claude.rs:1954` is empty. tireless consumes
them (`tireless_agent::claude::parse_limit_signal`) and holds the lane until
the reported reset. This is the most valuable signal available to an
unattended driver, because it reports what the provider thinks rather than
what we guessed, and it is checked **first**, ahead of our own optimism.
4. **Circuit breaker.** N consecutive failures (default 3) stop the lane
entirely until an operator intervenes. Repeated failure usually means
something retrying will not fix, and every retry still costs tokens.
Forge politeness is separate and equally deliberate: a floor on poll interval
(120s, config), conditional requests using the stored `ETag`, per-repo jitter so
N repos do not fire together, and backoff with jitter on `429`/`5xx`.
An optional quiet window suspends both polling and claiming.
---
## 6. Architecture
### 6.1 Crates
Per `generic.md` §1, with one addition noted below.
| Crate | Role |
| --- | --- |
| `tireless-entities` | domain types, no I/O. Exports TS bindings for the dashboard via `ts-rs`. |
| `tireless-core` | routing, budgets, job lifecycle. Declares ports; depends on no adapter. |
| `tireless-data` | Postgres + forge clients (Gitea, GitHub). |
| `tireless-agent` | **addition** — spawns and drives Claude Code and OpenCode. |
| `tireless-api` | binary: Axum REST/JSON on `/v1`. |
| `tireless-worker` | binary: `poll` and `run` roles. |
| `tireless-cli` | binary: operator CLI (`tireless`). |
`tireless-agent` is a deviation worth stating: process orchestration is not data
access, and it is shared by the runner and the CLI (which can dry-run a single
job), so §1's "extract when the second consumer appears" test is met.
### 6.2 Deployment
| Concern | Value |
| --- | --- |
| Host | `bob.hanzalova.internal` |
| API port | **23296** — derived per `port-allocations.md` §3, registry updated |
| Ingress | `tireless.internal` on the hanzalova proxy, mesh-only, per-service internal cert |
| Dashboard | static, `/var/www/tireless`, served by nginx |
| Database | `magrathea.kosherinata.internal:5432`, mTLS |
| Deploy | Gitea Actions, build-and-rsync, musl static |
bob was chosen because it already hosts vibe-kanban and helexa-bench, and sits on
the same site as cortex (`hanzalova.internal:31313`) — so the highest-volume
path, OpenCode implementation runs, stays local rather than crossing the
WireGuard mesh.
### 6.3 Checkouts
No worktrees. Worktrees share one object store, which is the right trade for
many cheap branches of a repo you already have, and the wrong one here: jobs must
not be able to reach each other's state.
```
/var/lib/tireless/mirror/<forge>/<owner>/<repo>.git # bare, refreshed before use
/var/lib/tireless/job/<job-id>/<repo>/ # clone of the mirror
```
Cloning from a local path hardlinks objects rather than copying them, so a job
clone is fast and near-free on disk regardless of repo size — git never mutates
an existing object, so the hardlinks are safe. `origin` is then repointed at the
real remote, because the mirror is a cache, not the truth.
Branches are namespaced `tireless/<issue>-<slug>` so forge branch protection can
permit the bot there and nowhere else.
### 6.4 Identity
A dedicated `tireless` Gitea account, not the operator's. Its token is scoped to
issue and PR write; branch protection on each repo's default branch denies it
push. Three benefits: the audit trail distinguishes agent work from human work;
you can meaningfully review a PR you did not author; and revoking the agent does
not touch your own credentials.
### 6.5 systemd hardening
Full hardening set from `generic.md` §8 on all three units, with one documented
relaxation on `tireless-runner`: **`MemoryDenyWriteExecute=false`**. Both agents
are Node programs and V8's JIT requires write-then-execute pages; with it enabled
the agent aborts at startup. Per §8 only the one setting that breaks the service
is relaxed — the API and poller keep it.
---
## 7. Staged implementation plan
Each stage is independently deployable and independently verifiable, per
`generic.md` §14. The ordering is deliberate: **everything that can be proven
without spending tokens is proven first**, and the first token-spending
capability produces text (issues), not code.
### Stage 0 — Foundations *(scaffolded)*
Workspace, entities, routing and budget logic with tests, binaries that start and
stop cleanly, dashboard shell, deployment assets, CI.
*Done when:* `tireless-api` answers `/v1/ready` on bob:23296, the dashboard loads
at `tireless.internal`, all three units are active, and the deploy workflow is
green end to end.
### Stage 1 — Forge ingestion (read-only)
Gitea client with conditional requests; poll loop with interval floor and jitter;
Postgres schema and migrations; repo CRUD through the API and dashboard.
Reads issues, writes nothing to the forge. No claiming, no agents.
*Done when:* labelled issues in a real repo appear in the dashboard within one
poll interval, and a repo's schedule can be changed from the dashboard without a
redeploy.
*Why first:* proves the poll loop, rate discipline and repo configuration while
the blast radius is still zero.
### Stage 2 — Claiming and lifecycle (still no agents)
Job state machine, `FOR UPDATE SKIP LOCKED` claiming, lease expiry, label
mirroring, issue comments, reconciliation of stale labels. A **dry-run executor**
that posts what it *would* do instead of running an agent.
*Done when:* labelling an issue causes tireless to claim it, comment its intended
plan of action, and release it on lease expiry — with the full external protocol
exercised and not one token spent.
*Why here:* the claim protocol is the part most likely to have subtle bugs, and
this is the last stage where those bugs are free.
### Stage 3 — Claude Code executor
Spawn the pinned CLI, read `stream-json`, capture session id for `--resume`,
record `apiKeySource` as billing mode, consume `rate_limit_event` into the
governor, enforce budgets and the circuit breaker.
First real capability: `tireless/plan` on a real issue produces an epic and child
issues.
*Done when:* a planning run completes against a real issue, the children are
sensible, the journal shows the billing mode, and an artificially lowered window
budget demonstrably holds the lane.
*Why planning first:* the output is issues, not code. A bad plan is a comment
thread; a bad implementation is a branch. Start where mistakes are cheapest.
### Stage 4 — Git and PR pipeline
Mirror cache, per-job clone, branch, commit, push, open PR. Wire the Claude Code
implementation path. Idempotent re-runs against existing branches and PRs.
*Done when:* an issue labelled `tireless/implement` yields a reviewable PR from a
protected-branch-respecting bot account, and re-running the job updates rather
than duplicates.
### Stage 5 — OpenCode executor
Spawn `opencode serve` on loopback with a per-spawn password, drive it over HTTP,
target helexa cortex. Enforce `assert_not_anthropic` from config. Route
plan-descended implementation jobs here.
*Done when:* a child issue created by a stage-3 planning run is implemented
end-to-end by OpenCode on the GPU fleet, with zero subscription usage.
### Stage 6 — Scheduling and dashboard control
Schedule editing, repo add/remove, lane pause/resume, budget and limit-signal
display, run history with per-run billing mode.
*Done when:* the operator can add a repo, change its cadence, and pause the
Claude Code lane without touching a shell.
### Stage 7 — Hardening
Dead-letter semantics for repeatedly failing jobs, Prometheus metrics, alerting
on tripped breakers, retention and cleanup of job directories, and the optional
container isolation backend behind the existing executor interface.
---
## 8. What is lifted from vibe-kanban
vibe-kanban is the reference implementation for driving these two agents. It is
*not* a dependency — tireless reimplements the parts it needs — but these are the
files worth reading before writing the corresponding stage.
| Concern | vibe-kanban reference |
| --- | --- |
| Executor interface | `crates/executors/src/executors/mod.rs:222` (`StandardCodingAgentExecutor`) |
| Claude Code spawn + control protocol | `crates/executors/src/executors/claude.rs:619` |
| Pinned agent package | `claude.rs:61`, `opencode.rs:92` |
| Session resume | `claude.rs:370` (`--resume`, `--resume-session-at`) |
| Session id extraction | `claude.rs:891` |
| `apiKeySource` / billing detection | `claude.rs:911` |
| `rate_limit_event` (parsed, then dropped) | `claude.rs:1954`**tireless does not drop it** |
| OpenCode loopback server | `opencode.rs:92`, `opencode/sdk.rs:405` (basic auth) |
| Process-group kill for orphaned `npx` children | `opencode.rs:75` (`Drop` impl) |
That last one is worth pre-empting rather than rediscovering: vk's comment notes
that `kill_on_drop` proved unreliable and leaked orphaned processes, which is why
it kills the whole process group explicitly. An unattended service accumulating
orphaned Node processes would be a slow, confusing failure.
---
## 9. Risks and open questions
**The subscription arrangement can be withdrawn.** Accepted, explicitly. The
mitigation is that the API-key fallback is a config switch (§3.2), so the failure
mode is a billing change rather than a rewrite.
**A headless subscription login is a manual step.** The OAuth flow must be
completed interactively as the `tireless` service account on bob. It is scripted
as far as it can be and documented in `script/infra-setup.sh`. If the token ever
requires reauthentication, the runner fails its preflight rather than silently
falling back to an API key.
**Unattended agents with commit rights are a real exposure.** Bounded by: a bot
account that cannot push to any default branch; hardened units; per-job clones;
and human review before merge. Stage 7's container backend tightens this further,
and the executor interface is shaped so it can drop in without touching
agent-driving code.
**Plan quality is unproven.** The whole economic argument — Opus plans, local
models implement — rests on tireless-authored plans being specific enough for a
27B model to execute. Stage 5 is where that assumption meets evidence. If it
fails, the fallback is routing more implementation to Claude Code, which costs
subscription budget but not a redesign.
**Not yet decided:** whether a failed implementation should automatically open a
`tireless/blocked` issue describing what it could not do, or simply comment on
the original. Deferred to stage 4, when there is real failure data to look at.

74
readme.md Normal file
View File

@@ -0,0 +1,74 @@
# tireless
Watches labelled issues on Gitea (and GitHub, for legacy repos), claims them, and
either decomposes them into an epic with child issues or implements them and
opens a pull request. A human reviews everything; nothing merges itself.
Two coding agents do the work, each spawned as the vendor's own binary:
- **Claude Code** — planning, and implementation of issues that need
interpretation. Uses the operator's Claude subscription by default, or
pay-as-you-go if an API key is supplied.
- **OpenCode** — implementation of issues that a tireless plan already specified,
against the self-hosted [helexa](https://git.lair.cafe/helexa/helexa) fleet.
Never Anthropic; enforced at startup.
The rule of thumb: **Claude Code gets judgement, OpenCode gets specification.**
Full design, constraints and the staged implementation plan:
[`doc/plan/design.md`](doc/plan/design.md).
## Status
Stage 0 (foundations) is scaffolded. Ingestion, claiming and the agent lanes are
not built yet — see §7 of the design document for what lands when.
## Build
```sh
cargo test --workspace
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dashboard && npm ci && npm run build
```
## Run locally
```sh
cargo run -p tireless-api -- --config ./config.toml
cargo run -p tireless-worker -- --config ./config.toml poll
cargo run -p tireless-worker -- --config ./config.toml run
cd dashboard && npm run dev # proxies /v1 to 127.0.0.1:23296
```
`tireless preflight` verifies configuration and credentials without starting a
service: it reports which billing mode a Claude Code run would use and asserts
the OpenCode lane is not pointed at Anthropic.
## Deploy
CI-driven via Gitea Actions on merge to `main`
(`architecture/deployment-gitea-actions.md`). One-time host provisioning —
including the interactive Claude Code login and the Gitea bot account — is
`script/infra-setup.sh`.
| | |
| --- | --- |
| Host | `bob.hanzalova.internal` |
| API port | `23296` (registered in `architecture/port-allocations.md`) |
| Dashboard | `https://tireless.internal` (mesh only) |
| Database | `magrathea.kosherinata.internal:5432`, mTLS |
## Conventions
Follows [`lair/architecture`](https://git.lair.cafe/lair/architecture);
`generic.md` is the baseline. Two deliberate deviations:
- **`tireless-agent` crate** beyond the standard entities/core/data split.
Process orchestration is not data access, and it is shared by the runner and
the CLI. (§1)
- **`MemoryDenyWriteExecute=false` on `tireless-runner`.** Both agents are Node
programs and V8's JIT needs write-then-execute pages. The API and poller keep
the setting. (§8)

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-musl"]

143
script/infra-setup.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
#
# One-time host provisioning for tireless.
#
# Run by an operator from a workstation with full sudo — NOT by CI. See
# architecture/deployment-gitea-actions.md §2. Idempotent: re-running with no
# changes is a no-op beyond file copies.
#
# Per architecture/generic.md §7 this script never suppresses errors. Where a
# command may legitimately fail (a service not yet installed), the failure is
# handled explicitly and visibly.
set -euo pipefail
APP=tireless
API_HOST="${API_HOST:-bob.hanzalova.internal}"
API_PORT="${API_PORT:-23296}"
RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}"
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m warn\033[0m %s\n' "$*" >&2; }
fatal() { printf '\033[1;31mfatal\033[0m %s\n' "$*" >&2; exit 1; }
[[ -f $RUNNER_PUBKEY ]] || fatal "runner public key not found at $RUNNER_PUBKEY.
The keypair is maintained at ~/.ssh/id_gitea_ci on roosta and is shared by every
project's deploy. Copy it — do not generate a new one."
# ---------------------------------------------------------------------------
# 1. gitea_ci account, key, journal access, scoped sudoers
# ---------------------------------------------------------------------------
provision_host() {
local host="$1"
info "provisioning $host"
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then
warn "$host unreachable; skipping (re-run once it is back)"
return 0
fi
ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \
--shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"'
ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh'
rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \
"$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys"
ssh "$host" 'sudo usermod -aG systemd-journal gitea_ci'
# Scoped sudoers — exactly the commands the deploy runs, nothing broader.
# Named <app>_gitea_ci so other apps on this host keep their own drop-in.
ssh "$host" "sudo tee /etc/sudoers.d/${APP}_gitea_ci >/dev/null" <<SUDOERS
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-worker
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/tireless/config.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/tireless.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-poller.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-runner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/tireless-api.xml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/tireless/
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-poller.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-runner.service
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker /usr/local/bin/tireless /etc/tireless /var/lib/tireless /var/www/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp ${API_PORT}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --query-service=tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone=* --add-service=tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --add-service=tireless-api
SUDOERS
ssh "$host" "sudo visudo -cf /etc/sudoers.d/${APP}_gitea_ci"
# ------------------------------------------------------------------------
# 2. Service account, directories, cert ACL
# ------------------------------------------------------------------------
ssh "$host" 'sudo install -d -o root -g root -m 0755 /etc/tireless'
ssh "$host" 'sudo install -d -o tireless -g tireless -m 0750 /var/lib/tireless || \
echo "tireless user not created yet — first deploy runs systemd-sysusers"'
# The service account needs to read the host key for mTLS to Postgres (§11).
ssh "$host" 'sudo setfacl -m u:tireless:r "/etc/pki/tls/private/$(hostname -f).pem" || \
echo "deferred: tireless user does not exist yet"'
# SELinux: the API binds a non-standard port, which must be labelled before
# the first start or the bind is denied (§10).
ssh "$host" "sudo semanage port -l | grep -qE '^http_port_t.*\\b${API_PORT}\\b' \
&& echo 'port ${API_PORT} already labelled' \
|| sudo semanage port -a -t http_port_t -p tcp ${API_PORT}"
info "$host provisioned"
}
provision_host "$API_HOST"
# ---------------------------------------------------------------------------
# 3. Manual steps that cannot be automated
# ---------------------------------------------------------------------------
cat <<'MANUAL'
Remaining one-time steps (operator, on the target host):
1. Claude Code subscription login.
The OAuth flow is interactive and must be completed *as the service account*,
because Claude Code reads credentials from $HOME:
sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.119
# then: /login, and complete the browser flow
This writes /var/lib/tireless/.claude.json. The token refreshes in place,
which is why the unit grants ReadWritePaths=/var/lib/tireless.
Skip this only if you intend to run pay-as-you-go, in which case put
ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both:
the API key silently wins, and the subscription goes unused.
2. Gitea bot account.
Create a dedicated `tireless` user on git.lair.cafe (not your own account),
generate a token scoped to issue + PR write, and put it in
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless).
Then, for each repo tireless should work on:
- add `tireless` as a collaborator with write access;
- enable branch protection on the default branch, denying `tireless` push;
- confirm it can still push refs matching `tireless/*`.
The protection rule is what keeps an unattended agent from writing to main.
Verify it rather than assuming it.
3. Postgres role and ident mapping (architecture/generic.md §5).
On magrathea AND frankie:
- create role `tireless_rw`, and a `tireless` database;
- drop /var/lib/pgsql/18/data/pg_ident.conf.d/<this-host-fqdn>.conf
containing: cert_cn <this-host-fqdn> tireless_rw
- sudo systemctl reload postgresql-18
Both servers, or a failover locks tireless out.
MANUAL
info "infra-setup complete"