Keeps several repositories moving without an operator driving each change by hand. It watches Gitea (and GitHub, for legacy repos) and does three things:
Those chain into a loop with exactly two human gates: a person decides what enters the system, and a person decides what merges. Discovery proposes but never admits its own proposals; nothing merges itself.
Two coding agents do the work, each spawned as the vendor's own binary:
The rule of thumb: Claude Code gets judgement, OpenCode gets specification.
Full design, constraints and the staged implementation plan:
doc/plan/design.md.
Stage 0 (foundations) is built, deployed and verified.
Working: the domain model, routing, budgets, plan validation, the policy guards,
configuration loading and validation, the four system prompts, and preflight.
All three units run on bob, the dashboard is served at
https://tireless.internal, and the deploy workflow is green end to end.
Nothing is polled or claimed yet — that is stage 1. The runner is up but has no job store to claim from.
Not built: Postgres persistence, the forge clients, the poll loop, and every agent executor. Stages 1–8 in §7 of the design document say what lands when.
tireless is its own first tracked repo — see design.md §10 for what that implies, including which parts of this repo are deliberately routed to the stronger lane.
cargo test --workspace
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dashboard && npm ci && npm run lint && npm run build
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.
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 (binaries, units, job trees) |
| API port | 23296 (registered in architecture/port-allocations.md), bound 0.0.0.0, mesh-only |
| Ingress | nginx on the hanzalova proxy — not on bob |
| Dashboard | https://tireless.internal (mesh only), served from the proxy |
| Database | magrathea.kosherinata.internal:5432, mTLS |
Follows lair/architecture;
generic.md is the baseline. Three 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)AGENTS.md is a symlink to CLAUDE.md. Both agents look for their own
filename and the instructions are identical; a symlink is the only version of
this that cannot drift.36 activities
Done in 98f193d, together with #3.
Step 3 says "surface the new ETag so the caller can store it" — but
list_opted_in_issues returned a bare Vec<DiscoveredIssue>, so there was
nowhere to put it. The only copy would have stayed inside the client, and a
poller that looked correct would have re-fetched every repo in full on every
tick, forever, with nothing to show it was happening.
It returns an IssuePage now, which also carries not_modified. That
distinction turned out to matter more than the ETag: a 304 is not an empty
repo. A caller that conflated them would read every quiet poll as "every issue
disappeared" and abandon the jobs behind them. Carrying it in the type means a
caller has to decide rather than assume, and there is a test pinning it.
Pull requests are filtered out. Gitea's type=issues parameter should
exclude them, but an older server ignores it and returns both — and a pull
request enqueued as an issue would be planned or implemented as though it were
one. Cheap to guard, expensive to debug.
Retry-After is honoured but capped at 60s. A forge asking us to wait an
hour would otherwise stall the whole poll tick for every other repo.
Backoff is jittered. N repos throttled at the same moment must not all retry at the same moment and throttle each other again.
Writes return an error naming the stage they land in, rather than a silent
Ok. A no-op that succeeded would let stage 2 look finished while the forge saw
nothing — and there is a test asserting they do not even reach the network.
Twelve tests, no database needed, so these run in the ordinary cargo test:
If-None-Match, and 304 is reported as
not-modified rather than as empty or as an error;If-None-Match
could earn a 304 for a client that has never seen the issues;GitHubClient is deliberately still a stub. It is disabled in the shipped
config, and an unused implementation is one more thing to keep working for
nobody.
Done in 98f193d, together with #4.
enqueue(&[DiscoveredIssue]) cannot know a JobKind. DiscoveredIssue
carries labels but nothing says which mode they imply, and the trait takes no
protocol. The store now carries the LabelProtocol and core gained
routing::job_kind_for.
That forced a decision the spec did not mention: what happens when an operator applies several mode labels, which is easy to do by accident. Precedence is discover, then plan, then implement — planning beats implementing because a plan produces the implementation children, so running it first loses nothing, while the reverse silently discards the decomposition that was also requested.
claim_next(worker, allowed_lanes) had no lane to filter on. The #2 schema
stores kind and parent but not the routing result, and the labels that carry the
tireless/agent:* override are not stored at all — so deriving a lane at claim
time would mean a forge request per claim. Migration 0002_job_lane.sql adds a
lane column, recorded at enqueue by a new routing::lane_for, which route
now delegates to so the two cannot drift. There is a test asserting they agree
across every combination.
The consequence is that the lane is a cache of operator intent, so
refresh_lane exists for the case where someone adds tireless/agent:oc to
something already queued. Without it the override would only work if applied
before the poller first saw the issue.
One statement: a CTE takes the row lock with FOR UPDATE SKIP LOCKED, the
update writes the claim. Select and update share a transaction without anyone
managing one by hand.
Returning a job to pending clears the claim — not because the code remembers
to, but because pending_holds_no_claim from #2 refuses the half-done row. That
is what makes lease expiry safe: a buggy release path fails loudly instead of
stranding an issue with a claim nobody holds.
renew_claim is extra, and needed: a run can outlast the 10-minute lease, and
without renewal the sweeper would hand a still-running job to a second worker.
It is guarded on claimed_by, so a worker cannot renew a claim it already lost.
Fifteen database tests, covering the things that are the database's to get right:
agent:oc override, and
returns nothing when every lane is held;Enum values round-trip through serde rather than a hand-written match, so the
schema's check constraints and the Rust types are provably the same vocabulary.
exactly_one_migration_ships_today failed the moment a second migration was
legitimately added. A test that fails on correct behaviour teaches people to
edit the assertion rather than think, so it now asserts what it was reaching
for: versions unique and ascending, starting at 1.
98f193d feat(data): implement JobStore and the Gitea read clientDone in 6545d19.
Runtime queries, not query!. No .sqlx offline metadata, no DATABASE_URL
needed to build, no cargo sqlx prepare --check in CI.
lairball — the other house project on this cluster — already does exactly
this: 19 uses of sqlx::query(, zero macros, no .sqlx directory, no Postgres
in CI. But the deciding argument is specific to tireless. Compile-time checking
makes a database a build dependency: regenerating .sqlx after any query
change needs a live Postgres. This crate is meant to be modified by a 27B model
working unattended (design.md §2.4), and a build that fails without a database
it cannot provision is a build that model cannot fix — its documented failure
mode being to improvise.
The cost is real and named in store.rs: a malformed query is caught by a test
against a real database rather than by cargo build. That is why the schema
tests below exist.
The live-issue index is partial, and getting it wrong is silent in both
directions. A plain unique (forge, owner, repo, number) — which is what
"enqueue is an upsert keyed on …" suggests — would forbid re-running a terminal
job, and would forbid the discovery lane outright, since discovery recurs
against one tracking issue on a cooldown (§2.6). Partial on the non-terminal
states gives at most one live job per issue plus unlimited history.
The claim index orders by created_at alone, leaving kind as a filter.
Leading with kind — which is what this issue's step 3 proposed — makes the
planner sort every pending row on every claim, because LIMIT 1 can otherwise
stop at the first match:
| index | buffers | plan |
| --- | --- | --- |
| (kind, created_at) | 720 | Bitmap scan → Sort 2503 rows |
| (created_at) | 4 | Index scan, stops at row 1 |
The gap grows with the backlog rather than staying fixed. INCLUDE (kind) was
measured too and dropped: FOR UPDATE visits the heap regardless.
A spelling that would have been permanent. rename_all = "snake_case" turns
Forge::GitHub into git_hub — in the database, the JSON API and the
generated TypeScript. Renamed to github while nothing is persisted and GitHub
support is still disabled. There is a test.
Check constraints are tested against the domain enums. Adding a JobKind
variant without a migration would otherwise fail as a constraint violation on
the first job of that kind — in production, unattended. Now it fails cargo test.
Against Postgres 18 (same major as the house cluster):
git_hub now being rejected;The live tests are #[ignore]d rather than skipping on a missing variable, so a
green cargo test never implies the schema was exercised. CLAUDE.md says how
to run them, and that an applied migration must never be edited — sqlx keeps a
checksum per version, so editing one makes every deployed database refuse to
start.
No JobStore impl (that is #3), no forge work, no reconciliation or lease-expiry
behaviour — the columns exist, including labels_synced_at for the poller
split in §6.4, but nothing reads them yet. No password field went near
Database.
6545d19 feat(data): add the initial schema and migrations9deeaaa feat(infra): provision the poller identity8586da2 fix(infra): grant the runner's account nothing; split labelling offb1e0177 feat(infra): provision the bot account, and move to a fork-based PR modelae285f5 docs: stage 0 complete — agent login done, all three units activebd65839 docs(infra): fix the agent login command — cd before npxGreen: run 8.
https://tireless.internal/v1/ready {"config":"ok","database":"not_implemented","forge":"not_implemented"}
https://tireless.internal/ HTTP 200
tireless-api active
tireless-poller active
tireless-runner failed (see below — this is correct)
tireless preflight Subscription / lair-helexa / Qwen3.6-27B (asserted non-Anthropic)
Eight faults. The two the issue predicted were both real, but neither was the one that would have cost the most to find later.
| # | Fault | Why it stayed hidden |
| --- | --- | --- |
| 1 | runs-on: fedora-43-rust is not a registered label | The job was never scheduled. Runs 2 and 3 have started_at: 1970-01-01 and are recorded as "cancelled", which tells you nothing |
| 2 | Vhost bound :443, owned by the stream SNI router | nginx -t passes; the symptom is the wrong certificate on a working handshake |
| 3 | Cert path was the host identity cert | Its SAN is bob's FQDN, so it fails only for a client verifying tireless.internal |
| 4 | --rsync-path word-split by an unquoted variable | Loud, but only on the half of the deploy that used the variable |
| 5 | restorecon on /var/lib/tireless, which cannot exist yet | The account that owns it is created later in the same deploy |
| 6 | Config shipped 0640 root:root; services run as tireless | Compounded by --chmod being a no-op without -p, so the fix would have worked once and silently stopped |
| 7 | API bound the clap default, ignoring [api] bind | The service looks perfectly healthy from bob — starts, logs "listening", answers a local curl. Only the proxy fails |
| 8 | Health probe expanded $unit on the remote side | It ran systemctl is-active .service: checking nothing, and would have reported healthy regardless |
The artifact-action risk this issue led with turned out to be moot — build and
deploy collapsed into one job, since the rust image already carries node, ssh
and rsync, so there is nothing to hand between jobs.
Six of the eight were silent. That is the part worth carrying forward: where a probe exists, it must be able to fail. The health probe now runs from the proxy over the mesh rather than bob's loopback, so closing firewalld breaks it.
Faults 4–8 were found by exercising the deploy directly against the hosts as
gitea_ci — every rsync destination, every sudo command, and the health probe
step extracted from the YAML and run as a file — rather than at six minutes per
attempt.
infra-setup.sh now does the ingress rather than describing it: mints the
tireless.internal cert through the JWK provisioner (removing the credential
even on failure), installs the vhost via sites-available + symlink, enables
[email protected], and registers the split-horizon record on both
routers — a record on one router NXDOMAINs at the other site. opn-cli has no
reconfigure verb, so the apply is a direct API POST; without it the name
resolves only whenever Unbound next happens to reload.
The served certificate was checked against disk by serial, per
internal-tls.md §3. They match.
tireless-runner is in failed because the interactive agent login has not been
done as the service account (script/infra-setup.sh step 1). That is the
invariant working — a runner with no credentials refuses to start rather than
pretending — and it is why the deploy tolerates that one unit failing.
Note the acceptance criterion "all three units active" contradicted this issue's own Out of scope section, which said the runner is expected to refuse. The Out of scope text was right; the criterion was over-specified when I wrote it.
architecture/internal-tls.md §5 says hanzalova vhosts bind :443 directly.
reverse-proxies.md §4 says both proxies use the 127.0.0.1:14443 tier and
notes hanzalova gained its router on 2026-07-30; the live bench.internal.conf
confirms it. Anyone following §5 gets fault #2 — which that same doc warns about
in §3. Worth a correction in the architecture repo.
ffa2ad7 docs: record stage 0 as deployed, and what the first deploy cost8067cde fix(deploy): expand $unit locally in the health probe42582cb fix(api): bind the address from config, not a clap defaulta5efa1e fix(deploy): make config readable by the service account, and assert modes267cb33 fix(deploy): quote --rsync-path, and let systemd own the state directory2e3aff7 fix(infra): drop the proxy web root from bob's restorecon grant3c7d95e fix(deploy): correct the runner label, the vhost listen line and the cert paths835e3e9 fix(deploy): put ingress on the proxy, and make the lint script runnableddb2574 docs: state the purpose, the autonomy boundary and the dogfooding plan581e6ae feat(discover): add the discovery lane and the autonomy boundaryc49b531 feat(config): load and validate configuration in every binary21c35ff docs(prompt): record helexa#179 as settled; shift the oc risk to precedence7b8308d feat(prompt): make the plan handoff a versioned, validated contract