All checks were successful
deploy / deploy (push) Successful in 5m35s
The interactive login is the one step in stage 0 that cannot be automated, and it is now done. preflight reports a present subscription login; tireless-runner is active alongside the api and poller. Recording it because the previous text said the runner was in `failed`, which was true for about an hour and is now exactly the kind of stale status this repo keeps trying not to leave lying around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
925 lines
46 KiB
Markdown
925 lines
46 KiB
Markdown
# tireless — design and staged implementation plan
|
||
|
||
**Status:** stage 0 built (workspace, domain model, policy, config, prompts,
|
||
deployment). Stages 1+ are specified but not implemented.
|
||
**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 system for keeping several repositories moving without an operator driving
|
||
each change by hand.**
|
||
|
||
It watches Gitea (and, for legacy repos, GitHub), and runs three kinds of work
|
||
against the repos it is given:
|
||
|
||
- **Discover** — survey a repository and *propose* issues worth opening.
|
||
- **Plan** — decompose an admitted issue into an epic and child issues, each one
|
||
specified well enough to be implemented by a model that cannot ask questions.
|
||
- **Implement** — produce a branch and a pull request.
|
||
|
||
Those three compose into a loop: proposals become issues, issues become specs,
|
||
specs become pull requests, and pull requests become the next thing a human
|
||
looks at. A human reviews the output. Nothing merges itself.
|
||
|
||
### The point
|
||
|
||
Each stage moves the operator's attention up a level.
|
||
|
||
Without tireless, the operator writes the plan and the implementation. With
|
||
planning and implementation automated, they identify what to work on and review
|
||
what came back. With discovery automated too, they are handed a ranked list of
|
||
*candidate* work and decide what deserves to exist at all.
|
||
|
||
That last step is the one that makes this worth building across several repos
|
||
rather than one. Reviewing a pull request is bounded work. Noticing that a repo
|
||
has drifted from its own design document, or that a stated guarantee has no test
|
||
behind it, is unbounded work that scales with the number of repositories and
|
||
gets skipped first when there are six of them. It is also exactly the kind of
|
||
reading a model does tirelessly and a person does once a quarter.
|
||
|
||
### Where the human stays
|
||
|
||
Automating discovery is the point at which a system like this can start
|
||
generating its own work indefinitely, so the boundary is drawn explicitly and
|
||
enforced in code rather than left to good intentions — see §2.5. In short:
|
||
**a human decides what enters the system, and a human decides what merges.**
|
||
Everything between those two points runs unattended.
|
||
|
||
### 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 an autonomous backlog.** It proposes; it does not admit its own
|
||
proposals. A discovered issue sits inert until a person opts it in (§2.5).
|
||
- **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
|
||
|
||
At the level of one job:
|
||
|
||
```
|
||
poll ──▶ enqueue ──▶ claim ──▶ prepare clone ──▶ run agent ──▶ deliver ──▶ report
|
||
▲ │
|
||
└──────────────────────────── reconcile ◀──────────────────────────────────┘
|
||
```
|
||
|
||
At the level of a repository, the three job kinds chain — with the human gates
|
||
marked, because they are the whole reason this is a supervised system:
|
||
|
||
```
|
||
┌──────────── survey, on a cooldown ────────────┐
|
||
│ │
|
||
▼ │
|
||
┌─────────┐ proposes ┌────────────┐ │
|
||
│ Discover│─────────────▶ │ proposed │ │
|
||
└─────────┘ (unadmitted)│ issues │ │
|
||
└─────┬──────┘ │
|
||
★ human opts in │
|
||
▼ │
|
||
┌───────────┐ │
|
||
│ Plan │ │
|
||
└─────┬─────┘ │
|
||
validated, children │
|
||
inherit admission │
|
||
▼ │
|
||
┌───────────┐ │
|
||
│ Implement │ │
|
||
└─────┬─────┘ │
|
||
▼ │
|
||
pull request │
|
||
★ human reviews & merges ─────┘
|
||
```
|
||
|
||
Two `★` gates, and only two. Everything between them is unattended.
|
||
|
||
Two systemd units, one binary:
|
||
|
||
| Unit | Role | Spends tokens? |
|
||
| --- | --- | --- |
|
||
| `tireless-poller` | finds 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/discover` | human | Survey this repository and propose work. Applied to a tracking issue (§2.6). |
|
||
| `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/proposed` | tireless | Opened by a discovery run. **Awaiting a human decision** — deliberately not opted in. |
|
||
| `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. |
|
||
|
||
¹ And by tireless in exactly one case: a child issue decomposed from a parent a
|
||
human already opted in. See §2.5 — that exception is the whole autonomy design,
|
||
and it is enforced by `tireless_entities::may_opt_in` rather than by convention.
|
||
|
||
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 |
|
||
| --- | --- | --- |
|
||
| `Discover` | Claude Code (Opus) | Deciding what is worth building is the highest-judgement task in the system, and the lowest volume. Its output sets what everything downstream spends its budget on. |
|
||
| `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 two lanes that *create* work —
|
||
discovery and planning — are the expensive ones, and they are also the two
|
||
bounded to a handful of runs per window. The lane that *consumes* work, which is
|
||
the bulk of it by volume, is free. The subscription is spent on the scarce thing,
|
||
while mechanical implementation runs on hardware already sitting in the office.
|
||
|
||
Spending is highest exactly where a mistake is cheapest to notice, which is the
|
||
same ordering the staged plan uses (§7).
|
||
|
||
### 2.4 The plan contract
|
||
|
||
The handoff between the lanes is the load-bearing interface in this design: an
|
||
Opus-authored plan must be specific enough for a 27B model to execute alone. That
|
||
is not left to chance in either direction.
|
||
|
||
**Both ends are shaped by system prompts.** `prompt/plan.cc.md` tells Claude Code
|
||
it is writing for a literal, competent, absent reader that cannot ask questions
|
||
and will fill any gap with an invention. `prompt/implement.oc.md` tells OpenCode
|
||
to execute the specification faithfully, stop when acceptance passes, and report
|
||
rather than improvise. They are two halves of one contract and are versioned
|
||
together — see `prompt/readme.md`.
|
||
|
||
The surfaces are not symmetric, and the difference matters:
|
||
|
||
| Lane | Mechanism | Ownership |
|
||
| --- | --- | --- |
|
||
| Claude Code | `--append-system-prompt` | Anthropic owns the base prompt; tireless appends |
|
||
| OpenCode | `AgentConfig.prompt` | tireless owns the whole prompt |
|
||
|
||
Claude Code also offers `--system-prompt`, which replaces its default outright.
|
||
tireless does not use it: the default carries the tool-use and repository
|
||
navigation scaffolding that makes Claude Code a coding agent, and discarding it
|
||
yields a less capable agent rather than a more obedient one.
|
||
|
||
**The middle is validated, not trusted.** A plan is parsed into
|
||
`tireless_entities::ChildSpec` and checked by `tireless_core::plan::validate`
|
||
before any implementation job is enqueued. Every child must carry five sections,
|
||
two of which exist specifically because a small model needs them and a human
|
||
reader does not:
|
||
|
||
- **Acceptance** must include at least one runnable command. Without a stopping
|
||
condition a literal implementer does not know when it is finished, and keeps
|
||
going — usually by rewriting adjacent code it was not asked to touch.
|
||
- **Out of scope** must be non-empty. Without a declared boundary nothing stops a
|
||
capable model expanding the work.
|
||
|
||
Plans are also checked for dangling and cyclic dependencies, and
|
||
`implementation_order` derives the order in which children may be started.
|
||
|
||
This makes the risk cheap to discover. A plan that fails validation costs one
|
||
comment and a `tireless/blocked` label, seconds after the planning run. The same
|
||
plan unvalidated costs an OpenCode run, a branch, and an operator's review
|
||
attention before anyone notices the spec was unusable.
|
||
|
||
**Dependency: helexa faithful passthrough — settled.** The OpenCode system prompt
|
||
reaches the model via OpenCode → cortex → neuron, relying on helexa's guarantee
|
||
of no injection, no rewriting, no defaults
|
||
([helexa/helexa#179](https://git.lair.cafe/helexa/helexa/issues/179), **closed
|
||
2026-08-02**).
|
||
|
||
Verified live on all three surfaces, streaming and non-streaming, with a negative
|
||
control proving nothing is injected when no prompt is sent, and pinned by
|
||
regression tests that assert on what cortex *forwarded upstream* rather than on
|
||
the reply. tireless can rely on it.
|
||
|
||
Two residual behaviours shape stage 5, and neither is a passthrough defect:
|
||
|
||
- **Several system messages are forwarded unmerged, in order, and the last one
|
||
wins.** OpenCode sends its own preamble alongside an agent's configured
|
||
`prompt`, so ordering decides whether `implement.oc.md` governs. The stage 5
|
||
question is therefore not *did the prompt arrive* but **did it arrive last**.
|
||
The failure is silent: an agent that behaves like a generic coding assistant,
|
||
ignoring `Out of scope`, with no error anywhere.
|
||
- **Thinking models on `/v1/responses` can return an empty string**
|
||
([helexa#223](https://git.lair.cafe/helexa/helexa/issues/223), open).
|
||
`/no_think` is honoured on chat/completions but not on Responses, where a small
|
||
`max_output_tokens` may be spent entirely on the reasoning block, yielding `""`
|
||
with `status: "incomplete"`. tireless must treat that as a distinct outcome
|
||
rather than an empty success, or it will burn a retry on a budget artifact.
|
||
|
||
**Model choice for this lane is an operator decision** (`generic.md` §14 —
|
||
placement of load on shared infrastructure). The fleet now offers, via cortex:
|
||
|
||
| Model | Alias | State | System prompt |
|
||
| --- | --- | --- | --- |
|
||
| `Qwen/Qwen3.6-27B` | `helexa/large` | warm, pinned on beast | verified |
|
||
| `Qwen/Qwen3-Coder-Next` | — | cold; feasible only on beast | shared code path, not live-tested |
|
||
| `Qwen/Qwen3-Next-80B-A3B-Thinking` | — | cold; feasible only on beast | shared code path, not live-tested |
|
||
|
||
A coder-specialised model is the obvious fit for executing a written spec, but
|
||
adopting one means displacing the pinned 27B that currently serves
|
||
`helexa/large`. That trade is not tireless's to make; it is recorded here and
|
||
revisited with stage 5 evidence.
|
||
|
||
tireless pins a **model name, not an alias**, for the same reason it pins agent
|
||
package versions: an alias that silently starts resolving to a different model
|
||
would change implementation behaviour between one job and the next with no
|
||
deploy and no signal.
|
||
|
||
### 2.5 The autonomy boundary
|
||
|
||
A system that proposes its own work and then acts on it has no natural stopping
|
||
point. This section says exactly where the human is, and why there.
|
||
|
||
| Transition | Automatic? | Why |
|
||
| --- | --- | --- |
|
||
| survey → proposed issues | **yes** | Output is text. A bad proposal costs an issue nobody opens. |
|
||
| proposed issue → planned | **no — human** | The only place anyone decides the work is worth doing at all. |
|
||
| plan → child issues | **yes** | Decomposing admitted work is not a new decision about scope. |
|
||
| child issue → implemented | **yes** | The child descends from something a human admitted. |
|
||
| pull request → merged | **no — human** | Review is the point. Nothing merges itself. |
|
||
|
||
The rule that produces this, stated once:
|
||
|
||
> **Admission is inherited, never invented.**
|
||
> tireless may opt an issue in only when it descends from an issue a human opted
|
||
> in. Discovery output has no admitted ancestor, so it is created unlabelled and
|
||
> waits.
|
||
|
||
This is `tireless_entities::may_opt_in`, with tests. It is deliberately a
|
||
function and not a config flag: an operator who wants more autonomy should have
|
||
to change code and pass review, because the failure mode is not a bad pull
|
||
request but an unbounded one.
|
||
|
||
**Why this specific gate and not another.** The alternatives were considered:
|
||
|
||
- *Gate every transition* — a human labels each plan child too. This makes
|
||
`parent_job_id`-based routing (§2.3) nearly dead code, and turns a plan of
|
||
eight children into eight relabelling chores, which is the work the system
|
||
exists to remove.
|
||
- *Gate only at merge* — discover → plan → implement runs unattended. One
|
||
enthusiastic survey then consumes an entire window budget on work nobody
|
||
agreed to, and the operator discovers this from their review queue.
|
||
|
||
Gating at ideation puts the single human decision at the only point where the
|
||
question is *"should this exist?"* rather than *"is this done correctly?"* — and
|
||
leaves the second question to code review, where it already lived.
|
||
|
||
**What this costs.** Proposals accumulate if nobody triages them. That is the
|
||
intended failure mode: a backlog of unadmitted suggestions is inert and free,
|
||
whereas a backlog of in-flight jobs is neither. Discovery is rate-limited
|
||
(§2.6) partly so the inert pile grows slowly enough to stay readable.
|
||
|
||
### 2.6 Anchoring the discovery lane
|
||
|
||
Discovery differs structurally from the other two kinds: planning and
|
||
implementation act on *an issue*, but a survey acts on *a repository*. It
|
||
recurs, and it has no natural subject.
|
||
|
||
tireless anchors it to a **long-lived tracking issue** in each repo, carrying
|
||
`tireless` + `tireless/discover`. That issue is the survey's subject, and each
|
||
run comments its findings there before opening anything.
|
||
|
||
The alternative — making discovery a repo-level scheduled activity, with
|
||
`Job.issue` becoming a sum type over issue-or-repo — was rejected. It is
|
||
structurally purer, and it costs: every layer that handles a job (claiming,
|
||
leasing, label mirroring, the API, the dashboard) would need to handle a job with
|
||
no issue behind it, in order to serve one job kind. The tracking issue buys the
|
||
same recurrence for free and brings two things the repo-level design would have
|
||
had to invent:
|
||
|
||
- **A place to report.** A survey that proposes nothing still has something to
|
||
say, and comments on the tracking issue are a readable history of what has
|
||
been considered and dismissed.
|
||
- **The usual controls.** Removing a label pauses discovery; the claim, lease and
|
||
reconciliation machinery all apply unchanged.
|
||
|
||
Because it recurs against a terminal job, a **cooldown** (`discover.cooldown_hours`,
|
||
default weekly) governs re-enqueue. Without it the tracking issue would
|
||
re-enqueue on the next poll after every run, and the most expensive lane in the
|
||
system would run continuously against a repo that had not changed.
|
||
|
||
A per-run proposal cap (`discover.max_proposals_per_run`, default 8) bounds the
|
||
output. A survey wanting to file forty issues has misunderstood the job, and the
|
||
operator should learn that from a truncated list and a note, not from their
|
||
notification inbox.
|
||
|
||
---
|
||
|
||
## 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` — binaries, units, config, job trees |
|
||
| API port | **23296** — derived per `port-allocations.md` §3, registry updated |
|
||
| API bind | `0.0.0.0:23296`, reachable across the mesh only |
|
||
| Ingress | `tireless.internal` on the **hanzalova proxy**, mesh-only, per-service internal cert |
|
||
| Dashboard | static, `/var/www/tireless` **on the proxy**, served by nginx |
|
||
| Database | `magrathea.kosherinata.internal:5432`, mTLS |
|
||
| Deploy | Gitea Actions, build-and-rsync, musl static |
|
||
|
||
**Ingress is not on bob.** nginx runs on the office proxy: it serves the built
|
||
dashboard from its own web root and reverse-proxies `/v1` to bob across the
|
||
mesh. Three artefacts encode that single decision and must agree —
|
||
`asset/nginx/tireless.hanzalova.conf`, the `0.0.0.0` bind in
|
||
`asset/config/config.toml.tmpl`, and `asset/firewalld/tireless-api.xml` opening
|
||
the port. If ingress ever moves onto bob, all three change together: loopback
|
||
bind, no firewalld service, vhost relocated. They were briefly inconsistent
|
||
during stage 0 — a loopback bind with a remote proxy — which is a configuration
|
||
that deploys green and then serves nothing.
|
||
|
||
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 *(built)*
|
||
|
||
Workspace and domain model; routing, budget, plan validation, policy and config
|
||
with tests; the four system prompts; binaries that load and validate their
|
||
configuration, run preflight and stop cleanly; dashboard shell; deployment
|
||
assets; CI.
|
||
|
||
Every constraint in §3 is enforced by code reachable from a binary's startup
|
||
path — `Config::validate` calls the Anthropic guard, `PromptSet::resolve` checks
|
||
the prompt set, and the runner refuses to start without a credential store.
|
||
There is no invariant here that is merely written down.
|
||
|
||
*Done when:* `tireless-api` answers `/v1/ready` from the proxy, the dashboard
|
||
loads at `tireless.internal`, all three units are active, `tireless preflight`
|
||
reports the expected billing mode, and the deploy workflow is green end to end.
|
||
|
||
**Complete, deployed and verified, 2026-08-07.** The workflow runs green end to
|
||
end; all three units are active on bob; `https://tireless.internal` serves the
|
||
dashboard and proxies `/v1`; the served certificate matches disk; and
|
||
`tireless preflight` reports a present subscription login with the OpenCode lane
|
||
asserted non-Anthropic.
|
||
|
||
The interactive agent login (`script/infra-setup.sh` step 1) is done. Until it
|
||
was, `tireless-runner` sat in `failed` — which was the invariant working, not a
|
||
defect: a runner with no credentials refuses to start rather than pretending.
|
||
|
||
Seven faults were found getting the first deploy green, and they are worth
|
||
knowing because most were invisible rather than loud:
|
||
|
||
| Fault | Why it did not announce itself |
|
||
| --- | --- |
|
||
| `runs-on: fedora-43-rust` — no such runner label | Job was never scheduled; two earlier runs sat queued and were reaped as "cancelled" |
|
||
| Vhost bound `:443`, which the stream SNI router owns | `nginx -t` passes; symptom is the wrong certificate on a working handshake |
|
||
| Cert path pointed at the host identity cert | Its SAN is bob's FQDN, so only a client verifying `tireless.internal` fails |
|
||
| `--rsync-path` word-split by an unquoted variable | Loud, but only on the half of the deploy that used the variable |
|
||
| `restorecon` on a directory that cannot exist yet | Ordering: the account that owns it is created later in the same deploy |
|
||
| Config shipped `0640 root:root`, unreadable by the service user | Compounded by `--chmod` being a no-op without `-p`, so the fix would have applied once and then silently stopped |
|
||
| API bound the clap default, ignoring `[api] bind` | The service looks perfectly healthy *from the host*; only the proxy that fronts it fails |
|
||
| Health probe expanded `$unit` remotely | Checked `.service`, i.e. nothing — and would have reported healthy regardless |
|
||
|
||
The pattern is worth carrying into later stages: the expensive faults were the
|
||
ones where a check passed while measuring nothing. Where a probe exists, it
|
||
should be able to fail — which is why the health probe now runs from the proxy
|
||
rather than bob's loopback.
|
||
|
||
### 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.
|
||
|
||
Apply `prompt/plan.cc.md` via `--append-system-prompt`; parse the result into
|
||
`PlanSpec` and gate it through `plan::validate` before creating any issue.
|
||
|
||
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 plan passes
|
||
validation, the children are sensible **when read as an implementer would read
|
||
them** — cold, with no other context — the journal shows the billing mode, and an
|
||
artificially lowered window budget demonstrably holds the lane.
|
||
|
||
This is also where plan quality is judged, while the only cost of a bad plan is a
|
||
comment thread. Iterate on `prompt/plan.cc.md` here, not in stage 5.
|
||
|
||
*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. Register a
|
||
custom OpenCode agent carrying `prompt/implement.oc.md` as its system prompt.
|
||
Route plan-descended implementation jobs here.
|
||
|
||
**Start with a precedence probe.** cortex passthrough is settled (§2.4), so the
|
||
open question is ordering: OpenCode sends its own preamble alongside the agent's
|
||
configured `prompt`, and the last system message wins. Capture what OpenCode
|
||
actually sends upstream and assert `implement.oc.md` arrives last — asserting on
|
||
model behaviour instead would not distinguish "prompt ignored" from "prompt
|
||
honoured but model disagreed".
|
||
|
||
If it does not arrive last, that is a stage 5 work item, not a blocker: options
|
||
are an OpenCode agent that suppresses the built-in preamble, or folding the
|
||
instructions into the user turn where ordering is under tireless's control.
|
||
|
||
**Handle `status: "incomplete"` explicitly** (helexa#223): an empty output from a
|
||
thinking model on the Responses surface is a token-budget artifact, not a failed
|
||
run, and must not consume a retry or trip the circuit breaker.
|
||
|
||
*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; the run
|
||
demonstrably respected its `Out of scope` section; and a captured upstream
|
||
request shows `implement.oc.md` in the winning position.
|
||
|
||
### Stage 6 — Discovery lane
|
||
|
||
The tracking-issue anchor (§2.6), `prompt/discover.cc.md` applied via
|
||
`--append-system-prompt`, proposal issues created with `tireless/proposed` and
|
||
**without** the opt-in label, the cooldown and the per-run proposal cap.
|
||
|
||
*Done when:* a survey of a real repository proposes issues an operator agrees
|
||
are worth reading; none of them are opted in; a second poll within the cooldown
|
||
does not re-enqueue; and an artificially small `max_proposals_per_run`
|
||
demonstrably truncates and says so.
|
||
|
||
*Why last among the capability stages, despite being cheap.* Its output is text,
|
||
so by the §7 ordering it looks like it belongs beside planning. But a proposal
|
||
has nowhere to go until the loop below it closes — a discovery lane running
|
||
against a system that cannot yet plan or implement just produces issues the
|
||
operator must triage by hand, which they could have written themselves. The
|
||
constraint here is not risk, it is that the value only exists once stages 3–5
|
||
work.
|
||
|
||
It is also the stage that makes tireless *continuous* rather than *on-demand*,
|
||
which is why §1 describes it as the point and §7 schedules it last. Those are not
|
||
in tension: it is the capstone, not the foundation.
|
||
|
||
### Stage 7 — Scheduling and dashboard control
|
||
|
||
Schedule editing, repo add/remove, lane pause/resume, budget and limit-signal
|
||
display, run history with per-run billing mode, and the proposal triage view —
|
||
the list of `tireless/proposed` issues awaiting a human, which is the operator's
|
||
main working surface once discovery runs.
|
||
|
||
*Done when:* the operator can add a repo, change its cadence, admit or dismiss a
|
||
proposal, and pause the Claude Code lane without touching a shell.
|
||
|
||
### Stage 8 — 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 8'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.
|
||
|
||
The plan contract (§2.4) narrows this considerably: paired system prompts shape
|
||
both ends, and structural validation rejects a plan lacking a runnable stopping
|
||
condition or a declared boundary before any implementation job is enqueued. What
|
||
remains genuinely unknown is *semantic* quality — whether a plan that satisfies
|
||
the schema is also correct and specific enough in substance. No validator
|
||
catches a well-formed plan that is simply wrong about the codebase, and there is
|
||
no unit test for whether a prompt produces good plans. That is measured on real
|
||
issues in stage 3, before stage 5 spends anything on acting on them.
|
||
|
||
**System prompt precedence in the OpenCode lane is unverified.** Passthrough is
|
||
settled (helexa#179 closed), but OpenCode's own preamble and
|
||
`implement.oc.md` both arrive as system messages and the last wins (§2.4). If
|
||
tireless loses that ordering, the symptom is not an error but a generic-feeling
|
||
agent that ignores `Out of scope` — the exact failure the prompt exists to
|
||
prevent, presenting as a prompt-quality problem rather than a plumbing one.
|
||
Checked first in stage 5, by asserting on the upstream request rather than on
|
||
behaviour.
|
||
|
||
**Adding a `qwen3_next` model to the lane would re-open a verified assumption.**
|
||
The system slot is not arch-branched — rendering goes through helexa's shared
|
||
`chat_template.rs` using each model's own `tokenizer_config` — so there is no
|
||
family-specific code to fail, and template tests cover the shared path. But
|
||
`Qwen3-Coder-Next` and `Qwen3-Next-80B-A3B-Thinking` have not been live-tested,
|
||
because both are feasible only on beast where the pinned 27B is resident. If the
|
||
operator decides the coder model is worth the displacement, probe it while warm
|
||
rather than forcing an eviction to find out.
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## 10. Dogfooding: tireless on tireless
|
||
|
||
`lair/tireless` is the first tracked repo, and its own backlog is maintained as
|
||
issues in the format this system consumes. That is deliberate: the plan contract
|
||
(§2.4) asserts that an Opus-authored spec is executable by a 27B model, and the
|
||
cheapest place to find out whether that is true is a repository whose conventions
|
||
are already written down and whose reviewer wrote the contract.
|
||
|
||
It also means the failure modes below are not hypothetical, and are worth
|
||
knowing before the first self-directed run.
|
||
|
||
### 10.1 A merged pull request restarts the thing that opened it
|
||
|
||
`deploy.yaml` runs on merge to `main` and restarts `tireless-runner`. If the
|
||
runner is mid-job — quite likely, since merging a tireless PR is exactly when
|
||
other tireless work is in flight — that job's agent is killed after
|
||
`TimeoutStopSec=120`, well inside a run ceiling of an hour.
|
||
|
||
This is survivable by design: the claim lease expires and the job returns to the
|
||
pool (§4.2), so nothing is lost except the tokens already spent. It is *not*
|
||
free, and it gets worse as concurrency rises.
|
||
|
||
Left as-is for now, because the alternatives all have a cost that is currently
|
||
larger than the problem: draining properly means a deploy that can block for an
|
||
hour, and skipping the restart means running a stale binary silently. Revisit in
|
||
stage 8, when metrics say how often it actually happens.
|
||
|
||
### 10.2 Some changes must not go to the cheap lane
|
||
|
||
The 27B implementation lane follows a specification faithfully — including
|
||
following it off a cliff. Certain files in this repo are constraint-bearing in a
|
||
way a literal implementer cannot be expected to infer:
|
||
|
||
| Area | Why |
|
||
| --- | --- |
|
||
| `CLAUDE.md` invariants | Each looks like redundant defensive code in isolation. That is precisely what makes them look like cleanup. |
|
||
| `prompt/*.md` | Behavioural specs, versioned as a set. A weakened instruction becomes a bad PR hours later, unattended. |
|
||
| `doc/plan/design.md` | The reasoning that makes the code make sense. |
|
||
| `crates/tireless-core/src/policy.rs` | Terms of service as code. |
|
||
|
||
Label issues touching these `tireless/agent:cc`. The override exists for exactly
|
||
this (§2.3), and the general rule holds: **work whose risk is "a capable model
|
||
tidies away something load-bearing" belongs on the lane that can read the
|
||
reasoning and weigh it.**
|
||
|
||
### 10.3 The quality gate is the acceptance command
|
||
|
||
Every child issue planned against this repo should carry the gate from
|
||
`CLAUDE.md` as its runnable acceptance:
|
||
|
||
```sh
|
||
cargo fmt --all
|
||
cargo clippy --all-targets --all-features -- -D warnings
|
||
cargo test --workspace
|
||
cd dashboard && npm run lint && npm run build
|
||
```
|
||
|
||
This is the stopping condition §2.4 requires, and it is unusually good at being
|
||
one: it is fast, it is total, and it fails loudly. A plan for this repo that
|
||
omits it has failed to do the easy part.
|
||
|
||
### 10.4 What dogfooding is expected to reveal
|
||
|
||
Named in advance, so the answers are evidence rather than rationalisation:
|
||
|
||
- **Does a plan written for a 27B model actually work?** §9's open question. The
|
||
first real answer arrives in stage 5.
|
||
- **Is the discovery prompt able to say "nothing this week"?** The failure mode
|
||
is manufactured findings — a survey that proposes work to look productive
|
||
costs the operator the exact attention the system exists to protect.
|
||
- **Does the two-gate boundary hold in practice, or does the operator start
|
||
rubber-stamping?** If admission becomes reflexive, the gate is decorative and
|
||
the honest response is to make discovery propose less, not to move the gate.
|