Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f22e43f
fix: make the check timeout configurable so real build gates can pass
bryfa Jul 23, 2026
a755d1e
docs+sandbox: RINGER_EXTRA_WRITABLE, and a scoreboard correction
bryfa Jul 30, 2026
b8794cb
Count what a run really costs, and let it stop itself
bryfa Sep 10, 2026
344092b
docs: model notes from the monorepo run2 and nexo run1 swarms
bryfa Sep 10, 2026
c58a208
Merge upstream: 21 commits, and fix a latent timeout bug it caught
bryfa Sep 10, 2026
d036025
Attribute cost to a requirement, and stop Codex reading as free
bryfa Sep 10, 2026
28cb853
Keep one estate's stack out of everyone's linter
bryfa Sep 10, 2026
c3fb0f3
Make the budget real, count tasks not attempts, and stop overclaiming
bryfa Sep 10, 2026
4b9d4ea
Pass 2: a stopped run reported success, and the default was still a p…
bryfa Sep 10, 2026
64c7e69
Pass 3: the estimate repeated the exact undercount it was written to …
bryfa Sep 10, 2026
c810e53
Pass 4: make the enforcement paths actually run in a test
bryfa Sep 10, 2026
14af31d
Pass 5: prices were unvalidated, and the source still carried one est…
bryfa Sep 10, 2026
c1ea6bc
Merge upstream main, and take the contributor credit the project requ…
bryfa Sep 10, 2026
1db7db3
Merge the cost controls into the checkout every factory runs
bryfa Sep 10, 2026
cd9c126
Prove a task can pass before buying it
bryfa Sep 10, 2026
1a5766e
Review pass 1: record the canary's verdict, and make the unreachable …
bryfa Sep 10, 2026
9c9c39e
Review pass 2: say what the deliverable probe actually proves
bryfa Sep 10, 2026
4004647
Review pass 3: baseline spawns no WORKERS, which is not the same as s…
bryfa Sep 10, 2026
a4247ad
Review pass 4: delete the demo's unopted canary bypass
bryfa Sep 10, 2026
62c44ee
Merge pull request #1 from bryfa/feat/prove-before-buy
bryfa Sep 10, 2026
a0b48f1
Record what nexo Run 1 taught, including the row that nearly cost a b…
bryfa Sep 10, 2026
2cbb366
Merge pull request #2 from bryfa/docs/run1-model-notes
bryfa Sep 10, 2026
0e398c1
The canary must resemble the batch it gates, not merely come first
bryfa Sep 10, 2026
f227dc9
Review pass 1: test the minority-MODEL case, which is the one that ac…
bryfa Sep 10, 2026
20c4f91
Merge pull request #3 from bryfa/feat/canary-representative
bryfa Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,126 @@ lint: clean (1 tasks)

A check that cannot fail is trusting the worker with extra steps.

### Budget: stop a run before it empties the key

A run's real cost lives in the worker's own log: engines that stream JSON report
what the provider charged for every model call. Ringer sums that and prints it,
per task and per run.

This is not the same number as `tokens` in the scoreboard. `worker_tokens`
records a *single* step, so it cannot be used for money — measured on real work
it ran 19x to 40x below the truth, which is how a swarm can report a small
fraction of its real cost and be restarted many times by an operator with no
way to see otherwise.

Two manifest keys stop a run that is going wrong:

```json
{
"budget_usd": 6.00,
"abort_after_repeated_failures": 3
}
```

- **`budget_usd`** — stops the run as soon as the spend is *visible*. Live costs
are re-read every few seconds and again the moment each worker exits; when the
figure crosses the budget, in-flight workers are terminated and queued tasks
are marked `SKIPPED` with the reason.

⚠️ It is **not** a hard ceiling, and calling it one would be a lie with a
number attached. A task's price is not knowable before it runs — it exists
only in what the worker has already written to its log — so nothing can be
reserved in advance. Parallel workers can therefore overshoot by whatever they
spend between one poll and the next. Set it below the number that would
actually hurt, not at it.

The budget weighs **measured plus estimated** spend, even though the summary
reports them separately: an engine that reports no cost of its own still
spends real money, and a budget blind to it is no budget for precisely the
case it is most needed.
- **`abort_after_repeated_failures`** — stop once this many **tasks** have
failed the *same* way (check exit code plus the first line it printed).
Counted per task, retries included, not per attempt: a single task failing
twice is one failure, not two. Not a consecutive streak — a tally per
signature, so A, B, A still counts two A failures. Workers finish in arbitrary
order, and a streak would miss exactly the repetition worth stopping on. A
manifest asking for something no worker can produce fails every task
identically; without this the run pays for all of them, and pays twice because
each failure is retried. Different failures do not trip it — that is an
ordinary bad run, not an impossible manifest.

⚠️ The signature is deliberately cheap, and cheap means blunt. Two unrelated
checks that both open with `FAIL` share a signature, and the counter is one
streak across parallel tasks rather than a per-family tally — so it reads
completion order, not causation. It is a circuit breaker, not a diagnosis.

Both default to off, so existing manifests behave exactly as before.

`lint` can also require that product work names the requirement it serves. This
is **off by default** — upstream ships no opinion about how you track work — and
turns on by naming the task types it applies to:

```toml
ticketed_task_types = ["code-fix", "code-feature"]
```

Spend you cannot attribute to a requirement is spend you cannot steer: on one
estate almost every product task was keyed by lane number rather than by the
work item it served, so "what did this requirement cost?" had no answer.

`lint` also refuses a manifest whose **spec** tells the worker to write an
absolute path outside its own task directory. A worker may only write inside its
own task directory as far as this check can reason: it also has a temp dir, but
that path is assigned at run time and is not knowable when linting. An absolute path in `expect_files` is
perfectly normal when the *check* produces it — the fix-swarm pattern exports a
patch out of the worktree that way — so only the spec naming the path is
flagged.

### Baseline: prove your checks before spending tokens

Lint reads the manifest; `--baseline` executes it — every task's `check` runs against the unmodified tree, spawning no workers and writing no eval rows:
Lint reads the manifest; the baseline executes it — every task's `check` runs against the unmodified tree, spawning no workers and writing no eval rows. **Every `run` does this first, automatically.** To see the baseline and dispatch nothing:

```bash
./ringer.py run swarm.json --baseline
```

Each check runs in a fresh scratch dir (a detached worktree when the manifest uses worktrees) through the same verifier as a real run. Reading the results: an assertion that demands the NEW behavior workers will build is *expected* to FAIL baseline; an assertion about UNCHANGED behavior that fails baseline is a bug in the check itself, and at run time it would burn a worker's attempts against something no model can satisfy. Fix the check before spawning.

A run is **refused** — before a single worker spawns, exit 2 — when the baseline finds either of the two shapes that make a task unbuyable:

| baseline says | meaning | dispatch |
|---|---|---|
| **FAIL** | the check demands behavior that does not exist yet | ✅ this is what you want |
| **pass** | the check is already green, so it is green at the end too, and cannot tell you the work happened | ❌ refused |
| **error** | the check could not be executed at all | ❌ refused |

It was a flag before, and a flag you have to remember is a flag that gets forgotten on the run that most needed it. The measured version of that: one run restarted sixteen times, whose worst restart failed 31 of 36 tasks for $19.43, because the manifest told every worker to write to a path the sandbox forbids.

⚠️ **Baseline does not prove a worker can write your deliverables**, and no phase that spawns nothing could. It probes declared *absolute* `expect_files` as the dispatcher sees them, so it catches paths the filesystem itself refuses; a sandbox can still deny a path that looks writable from here. Three layers divide that work: **lint** reasons about sandbox *scope* (a spec handing the worker an absolute path outside its own task directory — the shape of the $19.43 incident), **baseline** catches what no process could write at all, and the **canary** buys the rest once instead of once per task.

### Canary: buy one task before you buy the batch

A multi-task run releases its **first task alone**, judges it by its own executed check, and only then releases the rest. A bad verdict stops the run, and every remaining task is marked `SKIPPED` without spawning — so a manifest-wide fault costs one task instead of all of them.

The canary is not a smaller batch; it is a **stop** between the first task and the rest. A single-task run skips it and says so — that task is already the whole exposure.

```bash
./ringer.py run swarm.json --canary-confirm # also require a human to release the batch
```

`--canary-confirm` is deliberately not the default. A pause a human meets on every run becomes a keypress they learn to hit, which buys the appearance of a gate and none of the substance; the executed check is the verdict that always runs.

### Both gates have an escape, and it announces itself

```bash
./ringer.py run swarm.json --no-baseline "checks assert unchanged invariants on purpose"
./ringer.py run swarm.json --no-canary "tasks are fully independent, no shared manifest fault possible"
```

Each takes a **reason**, not a bare flag. The reason is printed as a `WAIVED (not proved, not verified)` banner and recorded in the run record — a gate nobody can bypass under pressure gets deleted rather than fixed, but a bypass that leaves no trace is the same as no gate. A blank reason is rejected rather than silently re-enabling the gate.

Every run record carries a `preflight` block with the baseline verdict per task and the canary's state, so *"was this checked?"* is answerable months later by someone who was not there.

## Make your agent actually use this

Between swarms, agents drift back to invisible inline work. Reminders decay, so enforcement ships with the product.
Expand Down Expand Up @@ -397,6 +507,7 @@ Every community PR that lands in main is credited here — that's a project rule
- [@davekopecek](https://github.com/davekopecek) (Dave Kopecek) — committed the design-reference fixture so the design-token guard runs on every machine (#30)
- [@snapsynapse](https://github.com/snapsynapse) (Sam Rogers) — graceful shutdown on SIGINT/SIGTERM with worker-tree cleanup and finished state, plus the 14-test end-to-end CLI regression suite (#4)
- [@mlava](https://github.com/mlava) (Mark Lavercombe) — named setup failures across every diagnostic surface (#37), `run --baseline`, the no-workers check preflight (#38), guidance on check-writing failure modes (#57), and early warnings for missing worker commands (#59)
- [@bryfa](https://github.com/bryfa) (Barry Faassen) — cost accounting from provider-reported per-step cost, a budget that stops a run, abort on repeated identical failures, and lint for deliverables a sandboxed worker cannot write (#129)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the philosophy and what gets a PR merged fast. The short version: small and scoped, rebased on current main, every claim backed by an executed test. Authorship is always preserved — where a maintainer pushes a mechanical fix to your branch, you remain the commit author.

Expand Down
51 changes: 51 additions & 0 deletions config.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ dashboard_port_base = 8787
# still fail unless this is true.
allow_full_access = false

# Install-wide default budget, in seconds, for a task's "check" command.
# A check is the gate — exit 0 is the ONLY pass — so it always has a finite
# bound and is killed (SIGTERM, then SIGKILL after 5s) when it runs over.
#
# Resolution order, highest first:
# 1. the task's own "check_timeout_s" in the manifest
# 2. this value
# 3. the built-in default, 60
# Any resolved value is clamped to 3600 (MAX_CHECK_TIMEOUT_S).
#
# Leave this at 60 unless most of your checks are slow. A real
# `dotnet build && dotnet test`, a container start, or a browser suite needs
# more, and the right place to say so is usually the task, not this file:
# { "check": "dotnet build && dotnet test ...", "check_timeout_s": 1800 }
# Raising it here slows down how quickly a genuinely hung check surfaces.
# check_timeout_s = 60

[update]
# Check origin/main before command dispatch and let a long-running HUD check
# again on this interval. Updates apply only as a clean ff-only move on main.
Expand Down Expand Up @@ -184,3 +201,37 @@ enabled = true
out = "~/.ringer/artifacts/{run_id}.html"
report_out = "~/.ringer/artifacts/{run_id}-report.html"
index_out = "~/.ringer/artifacts/index.html"

# ── Cost visibility for engines that bill on another account ──────────────
# Codex reports tokens but never a cost, so its work shows as free in every
# provider-reported total. On one estate that hid the large majority of tasks -- every
# code review among them -- which made review look free when it was the
# expensive half.
#
# token_scale: some harnesses report THOUSANDS of tokens, not units. Codex
# does: measured over a sample of real tasks its "tokens used" runs 12-230 where
# OpenCode reports hundreds of thousands for comparable work. Without this a
# Codex loop reads a thousand times cheaper than it is.
#
# Setting prices produces an ESTIMATE, always labelled as one and kept apart
# from measured cost. Leave them unset and the run says plainly that those
# tasks carry no cost at all, which is the honest answer -- an unknown cost
# must never default to zero.
#
# [engines.codex]
# token_scale = 1000
# price_in_per_mtok = 1.25
# price_out_per_mtok = 10.00

# Which task types must name the requirement they serve. Lint flags a product
# task with no "ticket", because spend you cannot attribute to a requirement is
# spend you cannot steer -- on one estate almost every product task was keyed
# by lane number rather than by work item, and what a requirement cost was
# simply unanswerable.
#
# OFF by default -- this is a policy, and a shared tool does not get to decide
# that its users track requirements the way its author does. Name the task types
# it should apply to and it switches on; a bakeoff, probe or research task serves
# no requirement and is never flagged.
#
# ticketed_task_types = ["code-fix", "code-feature"]
Loading