All checks were successful
deploy / deploy (push) Successful in 5m35s
Stage 2 writes the first label, and nothing about the label protocol can be dogfooded until an identity exists that is allowed to. Provisioned now rather than deferred. account tireless-poller (tireless-poller@lair.cafe), no ssh key — it never clones anything team lair/tireless-poller: Issues=write, PullRequests=read, Code=none, everything else none, scoped to named repos token write:issue, read:repository, read:user This is the one place an org team earns its keep. Collaborator permissions in Gitea are repo-wide, so granting issue write that way would hand this account code push too — the unit-level team grants labelling without it. The units now load different environment files, and that is not tidiness. The runner spawns coding agents as subprocesses, and subprocesses inherit the environment, so a label-capable token in the runner's environment is a token every agent run can read. poller.env is loaded only by tireless-poller.service, which spawns nothing. Be precise about what that buys: the separation is between processes, not users. Both units run as `tireless`, so the runner's uid can still read poller.env even though its process never loads it. An agent would have to go looking rather than find it handed over in `env` — a real difference, not a boundary. Closing it means separate service accounts or LoadCredential=; recorded as a stage 8 item so it is a known gap rather than an assumed guarantee. Verified each identity can do its own job and not the other's: tireless tireless-poller add label 403 200 fork repo works 403 read issues 200 200 One measurement caveat recorded in §6.4: the poller can still read repository contents despite Code=none, because lair/tireless is public. The unit permission bites on private repos — do not read that as the grant being wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
1035 lines
52 KiB
Markdown
1035 lines
52 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.
|
||
|
||
**Work happens in a fork, and the two remotes are not interchangeable.** After
|
||
cloning from the mirror, a job carries:
|
||
|
||
| Remote | Points at | Used for |
|
||
| --- | --- | --- |
|
||
| `upstream` | `<owner>/<repo>` | fetching the base branch. **Never pushed to.** |
|
||
| `origin` | `tireless/<repo>` | pushing the work branch |
|
||
|
||
Branch from `upstream/<default>`, push to `origin`, open the pull request across
|
||
repositories with a head of `tireless:<branch>`. Branches stay namespaced
|
||
`tireless/<issue>-<slug>`, now for legibility in a PR list rather than to give
|
||
branch protection something to match.
|
||
|
||
### 6.4 Identity and access
|
||
|
||
A dedicated `tireless` Gitea account, not the operator's — so 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.
|
||
|
||
**The runner's account is granted nothing.** It is an ordinary authenticated
|
||
user with no collaborator role, no team membership and no permission on any
|
||
`lair` repository beyond public read. That is not a limitation to work around —
|
||
it is the security property, and the fork is what makes it sufficient.
|
||
|
||
Measured on the bare account, because the boundary is only worth what it can be
|
||
shown to do:
|
||
|
||
| Operation | | Needed for |
|
||
| --- | --- | --- |
|
||
| push to `tireless/<repo>` (own fork) | works | the work branch |
|
||
| open a cross-repo PR, head `tireless:<branch>` | works | delivery |
|
||
| comment on an issue | works | reporting, blocked explanations |
|
||
| create an issue | works | plan children, discovery proposals |
|
||
| push to `lair/<repo>` | **denied** | — |
|
||
| delete the repository | **denied** | — |
|
||
| **apply a label** | **denied** | the label protocol |
|
||
|
||
So exactly one capability in §2.2 is out of reach, and it is the one that must
|
||
not be bought with code write.
|
||
|
||
**Labelling is a different process with a different identity.** The runner never
|
||
writes a label. It records state in Postgres, which §2.2 already makes the
|
||
authority, and a separate identity reconciles labels onto the forge. That maps
|
||
onto the split the two units already have (§2.1):
|
||
|
||
| Unit | Identity | Forge access | Holds |
|
||
| --- | --- | --- | --- |
|
||
| `tireless-runner` | `tireless` | no grant at all; pushes only to its own fork | agent login, ssh key |
|
||
| `tireless-poller` | `tireless-poller` | issue write via `lair/tireless-poller` team, code `none` | nothing else |
|
||
|
||
Verified as built, each identity able to do its own job and not the other's:
|
||
|
||
| | `tireless` | `tireless-poller` |
|
||
| --- | --- | --- |
|
||
| apply / remove a label | **403** | 200 / 204 |
|
||
| fork a repository | works | **403** |
|
||
| read issues | 200 | 200 |
|
||
|
||
The token that can touch issues is held by the process that never runs an agent;
|
||
the token held by the process running unattended agents can only push to a
|
||
repository nobody depends on. Neither half can do the other's damage.
|
||
|
||
**The two units load different environment files** — `/etc/tireless/poller.env`
|
||
and `/etc/tireless/tireless.env` — and that is not tidiness. The runner spawns
|
||
coding agents as subprocesses, and subprocesses inherit the environment, so a
|
||
label-capable token in the runner's environment is a token every agent run can
|
||
read. Keeping it out of that environment is the point.
|
||
|
||
Being precise about what that does and does not achieve: the separation is
|
||
between *processes*, not between *users*. Both units run as `tireless`, so the
|
||
runner's uid can read `poller.env` even though its process never loads it. An
|
||
agent would have to go looking for it rather than find it handed over in `env`,
|
||
which is a meaningful difference but not a boundary. Closing it properly means
|
||
either separate service accounts or systemd `LoadCredential=`, which puts the
|
||
credential in a per-unit tmpfs no other unit can read. Deferred to stage 8; noted
|
||
here so it is a known gap rather than an assumed guarantee.
|
||
|
||
One measurement caveat worth recording, since it will mislead whoever checks:
|
||
the poller's team grants code `none`, yet it can still read repository contents.
|
||
That is because `lair/tireless` is public — the unit permission bites on private
|
||
repositories. Do not read a successful content fetch as evidence the grant is
|
||
wrong.
|
||
|
||
It also removes a latent silent failure. Creating an issue *with* labels as an
|
||
unprivileged user returns **`201` and drops the labels** — no error, no warning,
|
||
an issue that simply never gets picked up. A Plan job stamping inherited
|
||
admission (§2.5) on its children would have failed exactly that way. Under this
|
||
split the runner does not attempt it: children are created bare, their intended
|
||
labels recorded in Postgres, and the poller applies them on the next reconcile.
|
||
The cost is that a child is not admitted until the next poll, which is a
|
||
scheduling delay of minutes and visible in the dashboard.
|
||
|
||
Credentials, with different jobs and different blast radii:
|
||
|
||
| Credential | Held by | Path |
|
||
| --- | --- | --- |
|
||
| ssh key, on the `tireless` account | runner | `/var/lib/tireless/.ssh/id_ed25519`, 0600 |
|
||
| API token, `write:repository` + `read:user` | runner | `/etc/tireless/tireless.env`, 0640 root:tireless |
|
||
| API token, `write:issue` + `read:repository` + `read:user` | poller | `/etc/tireless/poller.env`, 0640 root:tireless |
|
||
| Claude Code subscription login | runner | `/var/lib/tireless/.claude.json`, 0600 — stat'd, never read |
|
||
|
||
The poller has no ssh key and no git access of any kind; it talks only to the
|
||
forge API and to Postgres.
|
||
|
||
Token scope is a ceiling on what a token may attempt, never a grant. The runner's
|
||
token could carry `write:issue` and labelling would still be denied, because the
|
||
account behind it has no issue write. Scope narrows; permission is what widens.
|
||
|
||
`known_hosts` is pre-seeded for the forge, because an unattended git must not
|
||
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered
|
||
first.
|
||
|
||
### 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.
|
||
|
||
Two remotes, not one (§6.3): fetch the base from `upstream`, push the work branch
|
||
to `origin` — the bot's fork — and open the pull request across repositories with
|
||
a head of `tireless:<branch>`. Ensure the fork exists and is not stale before
|
||
branching; a fork whose default branch has drifted produces a PR full of
|
||
unrelated commits.
|
||
|
||
*Done when:* an issue labelled `tireless/implement` yields a reviewable PR opened
|
||
by the bot account from its own fork, and re-running the job updates rather than
|
||
duplicates.
|
||
|
||
The credentials and access model this depends on are already provisioned and
|
||
verified (§6.4) — the bot cannot push to a `lair` repository at all, so this
|
||
stage cannot accidentally write to one.
|
||
|
||
### 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.
|