9 Commits

Author SHA1 Message Date
rob thijssen
9deeaaa9c2 feat(infra): provision the poller identity
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
2026-08-07 18:31:59 +03:00
rob thijssen
8586da2cb9 fix(infra): grant the runner's account nothing; split labelling off
All checks were successful
deploy / deploy (push) Successful in 5m34s
Reverses the org team added an hour ago. It was solving the wrong problem.

Measured on the bare account, with no team and no collaborator role: pushing to
its own fork works, opening a cross-repo pull request works, commenting works,
creating an issue works. Pushing to a lair repo is denied and so is deleting it.
Exactly one §2.2 capability is out of reach — applying a label — and that is the
one that must not be bought by granting code write.

So the runner gets nothing at all, which is a better property than a carefully
scoped grant: there is no permission to review, no team to audit, and no rule to
forget on the next repo added. Adding a repo to its remit is now one step, a
fork, rather than three.

Labelling moves to a separate identity, which lines up with the split the two
units already have. The runner records state in Postgres — already the authority
per §2.2 — and the poller reconciles labels onto the forge under its own account
with issue write and no code access. 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.

That also removes a silent failure I found while testing this. Creating an issue
*with* labels as an unprivileged user returns 201 and drops the labels — no
error, an issue that never gets picked up. A Plan job stamping inherited
admission on its children would have failed precisely that way and looked fine.
Children are now created bare, with their intended labels recorded in Postgres
for the poller to apply.

The second identity is provisioned with stage 2, when the first label is
written. Nothing before then needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 18:26:01 +03:00
rob thijssen
b1e01777ff feat(infra): provision the bot account, and move to a fork-based PR model
All checks were successful
deploy / deploy (push) Successful in 5m36s
The design had tireless pushing `tireless/*` branches into the repo it was
working on, with branch protection denying it `main`. It now works the way an
outside contributor does: pushes only to its own fork, reaches the real
repository through a pull request, and has no write access to that repository at
all.

That is a stronger guarantee than the one it replaces. Branch protection is a
rule that can be edited, applies per repo, and is easy to forget on the next repo
added; a bot with no push permission cannot write to `main` whatever anyone
forgets.

The wrinkle is the label protocol. Labels are a write on the issues unit, and
Gitea collaborator permissions are repo-wide, so granting issue write as a
collaborator would hand back the code push the fork exists to avoid. Access is
therefore an org team with unit-level permissions -- lair/tireless-agent, with
Issues=write, PullRequests=write, Code=read, everything else none, scoped to
named repositories rather than the whole org.

Provisioned and verified end to end rather than assumed:

  push to the fork            succeeds
  push to lair/tireless       "User permission denied for writing."
  label an issue              200 / 204
  cross-repo PR from fork     opened as `tireless`
  delete the repository       403

Two credentials with different blast radii: an ssh key on the account for git
transport, and an API token scoped write:issue + write:repository + read:user for
issues and PRs. known_hosts is pre-seeded, because an unattended git must not
prompt and accept-new would trust whatever answered first.

Stage 4 gains the consequence: two remotes, and a fork that has to be checked for
staleness before branching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 18:18:31 +03:00
rob thijssen
ae285f5597 docs: stage 0 complete — agent login done, all three units active
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
2026-08-07 17:59:51 +03:00
rob thijssen
ffa2ad7f72 docs: record stage 0 as deployed, and what the first deploy cost
All checks were successful
deploy / deploy (push) Successful in 5m32s
The status said "nothing has been deployed to bob, the workflow has never run",
which stopped being true at run 8. Leaving it would be the same class of
misleading state this repo keeps trying to remove.

Also record the seven faults it took to get green, because six of the eight were
silent rather than loud: a runner label that meant the job was never scheduled,
a vhost that nginx -t accepts and the SNI router never reaches, a cert whose SAN
only the client checks, an ordering trap, a --chmod that stops applying after
the first deploy, an API that looks healthy from the host it is unreachable on,
and a health probe checking a unit name that expanded to nothing.

The pattern is the useful part for later stages: the expensive faults were the
ones where a check passed while measuring nothing.

Closes #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 17:06:59 +03:00
rob thijssen
ddb2574bbe docs: state the purpose, the autonomy boundary and the dogfooding plan
design.md described what tireless does to an issue but never what it is for, and
in one place said the opposite of the intent: §1 assigned "identifying what
should be worked on" to the operator, which is the thing discovery automates.
A reader — human or agent — would have concluded the system is human-triggered
only and never built the lane.

Rewrite §1 around continuous multi-repo development. Add §2.5, the autonomy
boundary, as a table of which transitions are automatic and which are human,
with the alternatives considered and why they lose. Add §2.6 on anchoring
discovery to a tracking issue rather than making Job.issue a sum type, and what
that buys. Add stage 6 for the lane, renumbering scheduling to 7 and hardening
to 8, with the reasoning for placing a cheap stage late.

Add §10 on tireless working on tireless: that a merged PR restarts the runner
that opened it and why that is survivable but not free, which areas must be
routed to the stronger lane because they are constraint-bearing, and the four
questions dogfooding is expected to answer.

Two new invariants. Admission is inherited (§2.5), and every constraint must be
reachable from a binary's startup path — the rule the previous commit's orphaned
guards violated.

AGENTS.md symlinks CLAUDE.md: both agents look for their own filename, the
implementation prompts tell them to, and a symlink is the only version of this
that cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 15:36:54 +03:00
rob thijssen
21c35ff2c3 docs(prompt): record helexa#179 as settled; shift the oc risk to precedence
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
Passthrough is verified and pinned by regression tests that assert on what
cortex forwarded upstream, so tireless can rely on it. The design no longer
carries it as an open dependency.

Two residual behaviours replace it, neither a passthrough defect:

Multiple system messages are forwarded unmerged and in order, last one wins.
OpenCode sends its own preamble alongside an agent's configured prompt, so the
stage 5 question becomes "did implement.oc.md arrive last", not "did it arrive".
That failure is silent -- an agent that reads as a generic coding assistant and
ignores Out of scope -- and would present as a prompt-quality problem rather
than a plumbing one, so stage 5 now asserts on the upstream request.

helexa#223: /no_think is ignored on /v1/responses, where a thinking model can
spend its whole output budget on reasoning and return "" with status
incomplete. Added RunOutcome::OutputBudgetExhausted so that is retryable but
blameless -- a mis-sized ceiling must not trip a circuit breaker on a lane with
nothing wrong with it. Config pins the chat/completions surface.

The qwen3_next gap is recorded as a gap, not a hole: the system slot is not
arch-branched, so there is no family-specific path to fail.

Fleet now offers Qwen3-Coder-Next, a better fit for executing a written spec,
but cold and feasible only on beast where the pinned 27B lives. Recorded as an
operator decision per generic.md §14 rather than taken here. Config also notes
why tireless pins a model name and never a helexa/* capability alias.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
2026-08-02 15:50:57 +03:00
rob thijssen
7b8308d34e feat(prompt): make the plan handoff a versioned, validated contract
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
helexa#179's application-owned system prompts let tireless shape both ends of
the cc->oc handoff, so the "will a 27B execute an Opus plan" risk becomes a
tunable rather than a hope.

Three prompts, versioned as one set: plan.cc.md tells Claude Code it is writing
for a literal, absent reader; implement.oc.md tells OpenCode to execute exactly
that and report rather than improvise; implement.cc.md covers unplanned issues.
PromptSet::load refuses a mismatched set, and tests assert the prompts mention
every section ChildSpec requires.

The middle is validated, not trusted. plan::validate rejects a plan before any
implementation job is enqueued unless every child carries a runnable acceptance
command (a stopping condition) and a non-empty out-of-scope list (a boundary) --
the two sections a small model needs and a human reader does not. Dangling and
cyclic dependencies are caught too, and implementation_order derives the start
order.

cc uses --append-system-prompt, never --system-prompt: replacing Claude Code's
default discards the tool-use scaffolding that makes it a coding agent. Pin
bumped to 2.1.220, the version this flag surface was verified against.

The oc path depends on helexa#179's passthrough guarantee, which is still open
and unverified for qwen3 arch templating. Stage 5 now opens with a PONG probe
rather than debugging it through a failed implementation run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
2026-08-02 15:26:05 +03:00
rob thijssen
4e42f87576 feat(tireless): scaffold workspace, dashboard and staged design plan
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
Autonomous issue-to-PR driver for Claude Code and OpenCode, structured per
lair/architecture generic.md.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
2026-08-02 12:46:42 +03:00