feat(tireless): scaffold workspace, dashboard and staged design plan
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:
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal 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 }
|
||||
144
.gitea/workflows/deploy.yaml
Normal file
144
.gitea/workflows/deploy.yaml
Normal 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
15
.gitignore
vendored
Normal 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
57
CLAUDE.md
Normal 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
3262
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
Normal file
70
Cargo.toml
Normal 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"] }
|
||||
93
asset/config/config.toml.tmpl
Normal file
93
asset/config/config.toml.tmpl
Normal 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"
|
||||
6
asset/firewalld/tireless-api.xml
Normal file
6
asset/firewalld/tireless-api.xml
Normal 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>
|
||||
40
asset/nginx/tireless.hanzalova.conf
Normal file
40
asset/nginx/tireless.hanzalova.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
36
asset/systemd/tireless-api.service
Normal file
36
asset/systemd/tireless-api.service
Normal 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
|
||||
37
asset/systemd/tireless-poller.service
Normal file
37
asset/systemd/tireless-poller.service
Normal 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
|
||||
65
asset/systemd/tireless-runner.service
Normal file
65
asset/systemd/tireless-runner.service
Normal 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
|
||||
2
asset/systemd/tireless.sysusers.conf
Normal file
2
asset/systemd/tireless.sysusers.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u tireless - "tireless service account" /var/lib/tireless /usr/sbin/nologin
|
||||
26
crates/tireless-agent/Cargo.toml
Normal file
26
crates/tireless-agent/Cargo.toml
Normal 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 }
|
||||
107
crates/tireless-agent/src/checkout.rs
Normal file
107
crates/tireless-agent/src/checkout.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
103
crates/tireless-agent/src/claude.rs
Normal file
103
crates/tireless-agent/src/claude.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
crates/tireless-agent/src/lib.rs
Normal file
25
crates/tireless-agent/src/lib.rs
Normal 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};
|
||||
101
crates/tireless-agent/src/opencode.rs
Normal file
101
crates/tireless-agent/src/opencode.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
23
crates/tireless-api/Cargo.toml
Normal file
23
crates/tireless-api/Cargo.toml
Normal 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 }
|
||||
80
crates/tireless-api/src/main.rs
Normal file
80
crates/tireless-api/src/main.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
24
crates/tireless-cli/Cargo.toml
Normal file
24
crates/tireless-cli/Cargo.toml
Normal 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 }
|
||||
93
crates/tireless-cli/src/main.rs
Normal file
93
crates/tireless-cli/src/main.rs
Normal 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(())
|
||||
}
|
||||
19
crates/tireless-core/Cargo.toml
Normal file
19
crates/tireless-core/Cargo.toml
Normal 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 }
|
||||
270
crates/tireless-core/src/budget.rs
Normal file
270
crates/tireless-core/src/budget.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
12
crates/tireless-core/src/lib.rs
Normal file
12
crates/tireless-core/src/lib.rs
Normal 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};
|
||||
96
crates/tireless-core/src/port.rs
Normal file
96
crates/tireless-core/src/port.rs
Normal 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,
|
||||
}
|
||||
127
crates/tireless-core/src/routing.rs
Normal file
127
crates/tireless-core/src/routing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
crates/tireless-data/Cargo.toml
Normal file
23
crates/tireless-data/Cargo.toml
Normal 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 }
|
||||
32
crates/tireless-data/src/forge.rs
Normal file
32
crates/tireless-data/src/forge.rs
Normal 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.
|
||||
48
crates/tireless-data/src/lib.rs
Normal file
48
crates/tireless-data/src/lib.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
22
crates/tireless-data/src/store.rs
Normal file
22
crates/tireless-data/src/store.rs
Normal 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.
|
||||
17
crates/tireless-entities/Cargo.toml
Normal file
17
crates/tireless-entities/Cargo.toml
Normal 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 }
|
||||
31
crates/tireless-entities/src/error.rs
Normal file
31
crates/tireless-entities/src/error.rs
Normal 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),
|
||||
}
|
||||
41
crates/tireless-entities/src/forge.rs
Normal file
41
crates/tireless-entities/src/forge.rs
Normal 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,
|
||||
}
|
||||
74
crates/tireless-entities/src/job.rs
Normal file
74
crates/tireless-entities/src/job.rs
Normal 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>,
|
||||
}
|
||||
45
crates/tireless-entities/src/label.rs
Normal file
45
crates/tireless-entities/src/label.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
19
crates/tireless-entities/src/lib.rs
Normal file
19
crates/tireless-entities/src/lib.rs
Normal 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};
|
||||
54
crates/tireless-entities/src/repo.rs
Normal file
54
crates/tireless-entities/src/repo.rs
Normal 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>,
|
||||
}
|
||||
90
crates/tireless-entities/src/run.rs
Normal file
90
crates/tireless-entities/src/run.rs
Normal 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>>,
|
||||
}
|
||||
24
crates/tireless-worker/Cargo.toml
Normal file
24
crates/tireless-worker/Cargo.toml
Normal 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 }
|
||||
102
crates/tireless-worker/src/main.rs
Normal file
102
crates/tireless-worker/src/main.rs
Normal 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
12
dashboard/index.html
Normal 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
3079
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
dashboard/package.json
Normal file
32
dashboard/package.json
Normal 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
28
dashboard/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
dashboard/src/api/client.ts
Normal file
37
dashboard/src/api/client.ts
Normal 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' }),
|
||||
};
|
||||
6
dashboard/src/api/generated/AgentKind.ts
Normal file
6
dashboard/src/api/generated/AgentKind.ts
Normal 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";
|
||||
27
dashboard/src/api/generated/AgentRun.ts
Normal file
27
dashboard/src/api/generated/AgentRun.ts
Normal 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, };
|
||||
11
dashboard/src/api/generated/BillingMode.ts
Normal file
11
dashboard/src/api/generated/BillingMode.ts
Normal 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";
|
||||
6
dashboard/src/api/generated/Forge.ts
Normal file
6
dashboard/src/api/generated/Forge.ts
Normal 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";
|
||||
7
dashboard/src/api/generated/IssueRef.ts
Normal file
7
dashboard/src/api/generated/IssueRef.ts
Normal 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, };
|
||||
23
dashboard/src/api/generated/Job.ts
Normal file
23
dashboard/src/api/generated/Job.ts
Normal 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, };
|
||||
6
dashboard/src/api/generated/JobKind.ts
Normal file
6
dashboard/src/api/generated/JobKind.ts
Normal 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";
|
||||
10
dashboard/src/api/generated/JobState.ts
Normal file
10
dashboard/src/api/generated/JobState.ts
Normal 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";
|
||||
44
dashboard/src/api/generated/LabelProtocol.ts
Normal file
44
dashboard/src/api/generated/LabelProtocol.ts
Normal 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, };
|
||||
23
dashboard/src/api/generated/PollSchedule.ts
Normal file
23
dashboard/src/api/generated/PollSchedule.ts
Normal 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, };
|
||||
7
dashboard/src/api/generated/PullRequestRef.ts
Normal file
7
dashboard/src/api/generated/PullRequestRef.ts
Normal 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, };
|
||||
6
dashboard/src/api/generated/RunOutcome.ts
Normal file
6
dashboard/src/api/generated/RunOutcome.ts
Normal 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";
|
||||
20
dashboard/src/api/generated/TrackedRepo.ts
Normal file
20
dashboard/src/api/generated/TrackedRepo.ts
Normal 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
51
dashboard/src/index.css
Normal 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
28
dashboard/src/main.tsx
Normal 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>,
|
||||
);
|
||||
12
dashboard/src/routes/Jobs.tsx
Normal file
12
dashboard/src/routes/Jobs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
dashboard/src/routes/Lanes.tsx
Normal file
18
dashboard/src/routes/Lanes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
dashboard/src/routes/Repos.tsx
Normal file
15
dashboard/src/routes/Repos.tsx
Normal 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
10
dashboard/src/vite-env.d.ts
vendored
Normal 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
23
dashboard/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
dashboard/tsconfig.tsbuildinfo
Normal file
1
dashboard/tsconfig.tsbuildinfo
Normal 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
23
dashboard/vite.config.ts
Normal 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
480
doc/plan/design.md
Normal 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
74
readme.md
Normal 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
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["x86_64-unknown-linux-musl"]
|
||||
143
script/infra-setup.sh
Executable file
143
script/infra-setup.sh
Executable 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"
|
||||
Reference in New Issue
Block a user