Skip to content

Plan 3a — autonomy: the poller, the supervisor, and board writes - #1

Open
slowernet wants to merge 26 commits into
mainfrom
plan-3a-autonomy
Open

Plan 3a — autonomy: the poller, the supervisor, and board writes#1
slowernet wants to merge 26 commits into
mainfrom
plan-3a-autonomy

Conversation

@slowernet

Copy link
Copy Markdown
Owner

Set Status to Ready, walk away, come back to a pull request.

Mill::Poller reconciles the board and sweeps comments behind a transactional
cursor. Mill::Supervisor prepares repos, claims to a cap, owns the worktree
lifecycle, walks each run in its own thread, and reaps against a verified
process identity. Mill::Board writes Status and re-drives a write that never
landed. Mill::Secrets injects a repo's environment. app.rb and config.ru
give both loops a home that Plan 4 fills in.

Plan: docs/superpowers/plans/2026-08-19-plan-3a-autonomy.md.
Design: Where this stands is updated; the rehearsal record sits beside
Plan 2's in the build order.

Demonstrated, not asserted

  • mill-scratch#4 — clean run, six stages, zero strikes, nobody watching.
  • mill-scratch#6 — blocked at plan with three questions, answered in a
    comment, resumed on its own session, finished. Zero strikes: asking is free.
  • Crash test — mill killed outright mid-stage, twice, each time leaving a
    live process group orphaned. Recovered both times, charging an attempt and no
    strike, and re-entering the stage it was in rather than the top of the route.
  • 19½ hours unattended overnight, surviving eight transient API failures
    with both threads alive in the morning.
  • rake test — 483 runs, 1617 assertions, 0 failures.
  • rake test:boundary — 14 runs, 30 assertions, 0 failures, against the real
    CLI. Required because this branch changed Mill::Spawn and
    Mill::Rules.env_for.

What running it found

Eleven defects, seven in this branch's own code, and the fixture suite could
not have caught any of the seven.
Five were the same shape: each component
correct alone, each tested alone, the defect in the handoff.

  • Mill::Workers assembled a supervisor without a board, so mill would have run
    the whole pipeline and never written a Status — and since a comment only means
    an answer while the board says Blocked, no blocked run could ever have been
    resumed.
  • Supervisor#walk returned a database row where finish expected the runner's
    state, so the first real block posted Blocked at : .`` to the issue. mill
    asked three good questions and threw all of them away.
  • Nothing owned the blocked → running transition, leaving a resumed run
    invisible to the reaper for the rest of its route.
  • A restarted run began at the top of its route, re-running every banked stage.
  • A launch the subscription refused was classified as a crash and charged a
    strike
    , which breaks a stated safety invariant. Mill::Stream already
    detected it and COST[:rate_limited] already priced it at zero; classify
    never asked.

The other two: doctor passed a database three migrations behind, because it
checked that tables existed and the missing thing was a column; and the log
scrubber would have corrupted the stream-json it parses back, given a short
value like DEBUG=true.

Two runbook steps also turned out not to work as written — the built-in Status
field can be neither deleted nor recreated, and disabling the board's workflows
is the one setup step with no API.

The lesson, recorded in the design doc

An adversarial review of this plan's code, before a line of it existed, found
twelve defects including four that would have stopped the factory silently. It
caught that two Mill::Supervisor instances would make the reaper kill healthy
stages. The fix was to share one instance — and the shared instance was built
without a board, which is the first bug listed above.

Reviews catch the layer they are looking at. Only running the assembled thing
catches the seam below it.

Not in this branch

The stall detector, sleep detection, the settle window and caffeinate, and the
log reaper are Plan 3b. The UI's routes are Plan 4. The fast and iterate
routes are Plan 5 — only two of the five triggers dispatch, and the rest record
no_route.

Untested against reality: branch collisions, the concurrency cap, secrets
injection against a repo that declares any, and the board re-drive. All four
fail loudly in normal use.

mill has still never run on Linux, which is now its primary target.

🤖 Generated with Claude Code

slowernet and others added 26 commits August 19, 2026 15:42
The design put pid, pid_started_at and host_boot_at on stage_attempts, but
Mill::Ledger writes that row when the attempt ends, in one insert. While a
stage is actually running there is no row, so a supervisor reaping a live
process would have nothing to identify it against. runs.pgid was already
there for this reason; the other three now sit beside it, along with
board_item_id, which the poller learns and the finishing run needs.

Mill::Spawn reports the identity through on_spawn the moment the group
exists, rather than only in its return value. A callback that raises —
a locked database being the likely way — would otherwise leave a running
process group that nothing has recorded, so the group is reaped before the
exception is allowed out.

Settings are parsed rather than coerced. MILL_CONCURRENCY=lots through
to_i is 0, which makes at_cap? true forever: mill claims nothing, with
every check green and nothing in the log. Mill.setting_int and
setting_float range-check and fall back with a warning naming the value.

320 runs, 1270 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fresh worktree holds tracked files only, so .env and config/master.key
are absent and a repo whose suite needs them fails identically on both
attempts — which reads as the stage being wrong and is not.
Mill::Rules.env_for was the hook the design left for this and carried one
variable; it now carries the repo's own environment, and GH_TOKEN for pr
and push alone. Setting GH_TOKEN is enough to re-point both gh and
git push at the scoped credential, because the helper the runbook
configures asks gh, and gh prefers GH_TOKEN over its stored login.

Values shorter than sixteen characters are injected but never redacted.
The scrubber gsubs literally over every log line, and the log is
stream-json mill parses back: an env file carrying DEBUG=true would turn
"success":true into "success":[redacted], which stops being JSON, and the
stage would then read as having produced no verdict and be charged a
strike for mill's own scrubber. No real credential is that short.

A secrets file whose mode has drifted off 600 is refused rather than read.

333 runs, 1294 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scanning ~/code for a matching origin is a laptop assumption: a server keeps
no working copies, so the clone has to come from somewhere. MILL_CLONES
lists directories of clones you keep — defaulting to ~/code on Darwin and
empty on Linux — and mill clones into ~/.mill/clones when nothing matches.

Two matches block the item rather than resolving. Choosing silently commits
the whole run to a checkout the operator did not pick, and the run then
works somewhere they are not looking.

git clone and git init have no repository to run inside, so they cannot go
through Git.run, which passes -C. They live in Mill::Git anyway: that module
being the only place mill runs git is what makes the rules about forcing a
checkout enforceable rather than aspirational.

345 runs, 1327 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…issing

Lazy and per-item: resolve or make the clone, set gc.auto and
maintenance.auto to 0 so a stage's commit cannot trigger a gc that rewrites
refs other runs are holding, read .mill.yml, and check the secrets it names
are present.

.mill.yml is read with git show against the base branch, never from a
checkout. An agent can edit that file in its own worktree, and that edit
must not weaken the next run.

Nothing here raises at the caller. A repo with a malformed .mill.yml, an
unquoted date in it, or a missing secret blocks that one item and names
what is wrong; left to raise, one badly configured repo would wedge the
poller in a retry cycle and stop every other item too.

357 runs, 1365 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mill has never written a Status. Every write is a network call that can
fail, and nothing else re-drives one: the poller only ever asks which items
are Ready. So a run that blocks while the network is down would show
Running forever, and because a comment's meaning depends on Status, the
answer to its questions would never be read as an answer.

Board records the decision first and confirms it second, because a crash
between the two must leave something redrive can act on. confirm re-reads
the label rather than taking it as an argument, and stamps board_status_at
only if the decision has not changed underneath it — redrive runs in the
poller thread while run threads decide, and stamping a stale label is worse
than not writing at all, since that stamp is the only thing that would have
caused a retry.

Only unreachability is swallowed. A board missing a Status option is a
configuration error, raises, and is checked once when the ids resolve
rather than at the moment mill first needs the option it lacks.

371 runs, 1402 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things stand between an item and a worktree and each has a different
answer. The cap means try later. A branch a live run holds means skip, and
say so once — a blocked run holds its branch indefinitely by design, so an
item queued behind one waits forever and silence reads as mill ignoring
you. A branch checked out in your own clone blocks the item and names it:
mill does not switch your clone and does not force the worktree, because
two live checkouts of one branch can diverge the ref without either side
noticing. A stale lock or worktree admin entry is cleared and claiming
carries on.

The row and the worktree are inserted together. A row committed before a
worktree that then fails to appear is a running run with no process and no
thread: nothing reaps it, because there is nothing to identify, and it
holds a concurrency slot for as long as the database survives. Two of those
and mill claims nothing ever again with every check green. The worktree is
not transactional, so a partial one is removed by hand before the error is
re-raised.

checked_out? does not rescue. A git failure means mill does not know
whether the branch is checked out, and answering "it is not" is the
rescue-into-a-pass that produces the two-checkout case above.

385 runs, 1434 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A route walk takes tens of minutes. A supervisor that walked one would
claim a single item and then stop reconciling, so each claimed run gets its
own thread and the cap is what bounds them.

Until now nothing told GitHub anything. Mill::Run returned a blocked run's
questions and only rake mill:run printed them, which is no use to anyone
who has walked away — so the supervisor posts them on the subject, names
the pull request when a run finishes, and says plainly when one fails.

A thread that dies marks its run failed rather than leaving it running
forever, because a run stuck in running is a concurrency slot nothing else
releases.

Mill::Run.adopt builds a Run from an existing row, and restores prior
verdicts only when that row is blocked — a fresh run has nothing to
restore. resume is now adopt plus call.

393 runs, 1450 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mill::Runner wrote current_stage only when it halted, so for the whole time
a stage was actually running the column was nil. Everything in this task
depends on knowing which stage a live run is in, so the runner now records
it before launching — without that, interrupt has nothing to charge and the
reaper silently does nothing, which is exactly the shape of failure that
looks like the feature working.

Three branches, in order, and nothing is signalled on the boot time alone:
kern.boottime moves when NTP corrects the clock, which it does routinely on
waking, so the live process is what settles it.

:ours means a thread is walking the run right now, not merely that no
process is recorded. pid and pgid are nil for the whole gap between two
stages — five times over on the plan route — so reading nil as "mill has
this in hand" would strand any run mill was restarted during, and each one
holds a concurrency slot nothing releases.

Interrupting is half the job. A run interrupted and not re-entered stays
running with no thread forever, and the poller skips its item because it
has an active run. reap now restarts it, unless interrupt just blocked it
for hitting the interruption cap, in which case it is waiting for a person.

A running row with no current_stage raises rather than quietly charging
nothing, because it means something above lost track of the run.

407 runs, 1473 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One idempotent question: which items are Ready with no active run. It
needs no dedupe key and heals itself when mill crashes mid-transition. The
label design that preceded it consumed change events, and four bugs came
from that shape — a relabelled issue deduped permanently, an item Ready and
Running at once, nothing clearing Running when a run was killed, and no
label change reaching a terminal state. A single-select cannot express any
of them.

Mill::Poller takes its supervisor as a required keyword and never builds
one. There must be exactly one per process: it alone knows which process
groups mill spawned and which runs have a live thread, and a second
instance answers "none" to both — a reaper holding that belief kills every
healthy stage it finds.

:no_branch and :no_spec carry no questions, because the answer is a branch
or a file rather than a decision. The generic block comment would post a
heading over an empty list and read as a bug in mill, so those two are told
plainly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments are genuinely events, unlike board state, so they are consumed
rather than reconciled. Two rules keep that honest and each has a specific
failure behind it.

The cursor advances inside the same transaction as the inserts, so a fetch
that stops partway writes no cursor and loses nothing. And the cursor is
actually sent to GitHub as `since`, which is the point of keeping one:
without it a run blocked for a week on a busy issue re-fetches its entire
comment history every tick and ends in a secondary rate limit that wedges
the poller.

The marker is matched at the start of a line that is not blockquoted.
GitHub quote-reply copies the source markdown including HTML comments, so
a whole-body search would silently discard the only channel in the design
that reaches a person.

The sweep is bounded to subjects with a live run, not every issue in every
repo the board touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Board Status decides what a comment is. While an item is Blocked every
comment on it is an answer and none of them starts a run — otherwise your
answer tries to start a second run, the uniqueness index refuses it, the
event retries until it dies, and the blocked run sits waiting for an answer
that had already arrived.

The event is marked processed and committed before the thread is spawned,
never inside the same transaction as it. A thread started inside an open
transaction writes to the same SQLite file from another connection while
this one holds the write lock; if the commit is what fails, the event rolls
back to pending while the thread it already spawned keeps running, and the
next dispatch starts a second walker on the same run — two agents in one
worktree, reached from inside the check built to prevent it.

Marking first would drop the answer if start then failed, which is what the
same-transaction rule exists to prevent, so fail_event is the compensation:
it returns the event to pending. running? is what stops a retry becoming a
second walker, and the cap binds on resumes because a blocked run is not
counted as running until its own thread says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app.rb and config.ru in reps shape: app.rb requires and defines the Roda
subclass, config.ru stacks what it needs and ends with App.freeze.app. Plan
4 fills in app/routes and the views against a boot path that already runs.

One supervisor, shared. Mill::Workers builds it and hands the same instance
to Mill::Poller, because it is the only object holding which process groups
mill spawned and which runs have a live thread — a second instance answers
"none" to both, and a reaper believing that kills every healthy stage it
finds about thirty seconds into every run.

The backoff cap is applied after the multiplier, not before. Before it, the
real ceiling was three seconds rather than five minutes, so an expired
token would have retried twelve hundred times an hour indefinitely.

Heartbeats are written under a mutex and read as a snapshot: two worker
threads write while a Puma thread reads.

The workers are built at class definition and started in config.ru.
Starting threads as a side effect of require means any test, console or
rake task that loads app.rb silently begins polling a real board. App.freeze
is also why they are built rather than memoised on first use.

Verified by booting it: both threads alive with fresh heartbeats, bound to
127.0.0.1:9494, nothing raised. 451 runs, 1553 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything Plan 3a added has a way of being quietly wrong, and a red doctor
is meant to be what stops it.

A secrets file whose mode has drifted off 600 still works — that is the
problem. A clone root that does not exist silently becomes "clone it
myself" for every repo, and mill then works in a checkout nobody is looking
at. A non-loopback bind with an empty admin list puts a kill switch and a
worktree deleter on the network with nothing in front of them. And a board
whose Status field is missing an option fails at the moment it matters — a
run blocking, or finishing — rather than at setup.

Checked against the real machine: the three local checks pass, and the
board check is red only because MILL_PROJECT is unset.

460 runs, 1573 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by running the rehearsal against a real, freshly created board
rather than by reading the docs back.

Section 3 said to delete the Status field and recreate it with mill options.
GitHub refuses both halves: "Only custom fields can be deleted" to the
delete, and "Name cannot have a reserved value" to the create. The built-in
Status field can be neither removed nor replaced. What does work is
updateProjectV2Field, which swaps the whole option list in place; the
runbook now carries the working mutation, and the warning that any option
omitted from that list is deleted along with its value on every item.

Section 4 said to turn off the built-in workflows without saying that this
is the one setup step with no API at all. The schema exposes enabled for
reading, which is how doctor checks it, but there is no mutation to turn
one off — deleteProjectV2Workflow exists and is a different thing. It also
now names the six a default project actually ships enabled, rather than a
remembered list that did not match.

The Status-options check added in Task 13 earned its place immediately: it
caught a board still carrying Todo / In Progress / Done, which would have
failed at the moment a run first blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both from running Plan 3a against a real board rather than a fixture.

Doctor reported the schema green against a database three migrations
behind. Its check asserted that five tables exist, and they did — what was
missing was a column. So the first claim raised "table runs has no column
named board_item_id" inside a worker thread, on every tick, while the one
command whose job is to say what is wrong before you start said nothing was
wrong. It now compares schema_info.version against the highest migration on
disk and names the rake task.

Mill::Workers built the shared supervisor without a board, so every
@board&.want in claim, finish and interrupt was a silent no-op. mill would
have run the whole pipeline correctly and never written a Status: the item
sits on Ready while a run works, finishes, and opens a pull request, and
because a comment only means an answer while the board says Blocked, no
blocked run could ever have been resumed. One board is now built once and
handed to both the supervisor and the poller.

The adversarial review caught the two-supervisor bug and missed this one,
which is the same wiring seam one layer down.

Worth recording: the failed claim left no orphan row. The insert raised
inside the transaction added in Task 6, run_id stayed nil, and nothing was
left holding a concurrency slot — which is exactly the CRITICAL that
transaction was written for, working on its first real failure.

462 runs, 1575 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while checking what would happen if I restarted mill against a run
that was mid-flight — before doing it, not after.

Runner#stage is `@stage ||= route_stages.first`, and @stage was only ever
assigned inside restore, which Mill::Run.adopt called only for a blocked
run. So the path added in Task 8 — the reaper interrupting a run and
starting it again — would have begun the route over. A run interrupted at
implement would re-run triage, plan and review:plan: `plan` writes its
artifact a second time, and the ledger counts fresh attempts against stages
that had already passed clean.

restore is now the blocked-run path — guard, reload, and the one sanctioned
strike reset — and reload is the part both callers need. A run the
supervisor interrupted only reloads: it resumes at the stage it was in with
nothing forgiven, because nobody answered anything.

Demonstrated rather than argued: the same run reports triage without reload
and implement with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first real block posted this to the issue:

    Blocked at ``: .

Mill::Supervisor#walk returned the run row from the database, and finish
announces from the runner state — which stage stopped, why, and the
questions it batched. A row carries none of those, so every field came out
nil and the comment said nothing.

Blocking is the mechanism the whole design rests on: the line can always
stop, and asking is free. That comment is the only channel that reaches a
person once you have walked away, so a block that reaches GitHub carrying
nothing is worse than a crash — the board says Blocked, the worktree waits,
and there is no way to learn what for.

Every existing test called finish with a hand-made state hash, which is
exactly why nothing caught it: the walker itself was never driven. The new
test drives the real walker through a scripted launcher and asserts on what
reaches GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by looking at a live resumed run: status=blocked while pid 58621 was
a claude process actively planning.

Nothing moved a resumed run back to running. Mill::Runner only writes the
status column when it halts or finishes, and the supervisor set it when it
claimed — so a run answered from a comment kept the row it had when it
stopped, for the whole rest of its route.

That lies in three ways at once. The board keeps saying Blocked while mill
works, finishes, and opens a pull request. The run does not count against
the concurrency cap, so the cap under-counts by however many resumed runs
are in flight. And reap queries running rows only, so if that stage died
the run was stranded with nothing able to recover it — the exact stranding
the identify fix was written to prevent, reached by a different door.

The transition belongs to the supervisor, which owns the run lifecycle: it
flips the row and tells the board before spawning the walker.

467 runs, 1587 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file read as though all of it worked, which matters more here than
elsewhere: CLAUDE.md points people at it first when terminology confuses
them, and it had no equivalent of the design doc Where this stands.

Five passages described unbuilt behaviour in the present tense. The fast
path was the damaging one — it told you to set Ready on an issue with no
linked branch, and mill now answers that with a comment telling you to run
gh issue develop and commit a spec. Following the doc got you the opposite
of what it promised. The mill: PR marker, review-comment and red-check
triggers, and Review: Deep are the others; all need routes that have no
prompts. They are marked rather than deleted, because the vocabulary is
what you will want when Plan 5 lands.

Four things were simply stale. Repo said "resolved local clone path" from
when mill could only use a clone you already had; it now searches
MILL_CLONES and clones into ~/.mill/clones when nothing matches. Project
ids are resolved on first write and memoised per process, not at bootstrap.
Event has a second terminal state, no_route. And the branch-checked-out
warning named ~/code/<repo>, which is the laptop assumption inverted this
morning.

One gap added rather than corrected: nothing said MILL_PROJECT is the
project number and not its node id. That cost real time today — a node id
looks like an id, and passing it gets a 404 that suggests nothing about
which of the two was wanted. The conversion query is now in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
triage let a spec through that said "add a report of stock levels, it
should be useful, make it fast", and plan spent 29,572 output tokens
discovering it was underspecified. triage was right by its own prompt — it
was asked about scope and route, and the spec is unambiguously one small
feature with a spec on its branch.

Two changes.

The opener said "when the answer is not obvious, block" and then listed two
closed decisions, which tells a stage two different things. It now names
the three it judges and says nothing else is its to judge.

And it gains a third decision, deliberately narrow: block only when a spec
names no exact value anywhere, says nothing about failure, and contains
nothing testable — all three together. That is the hopeless case, and it is
visible without reading a line of code.

The bar is high on purpose. Of the three questions plan asked about that
spec, two were answerable from the spec alone but the third — where a
threshold lives, given Item carries only sku, name and count — needed the
codebase and was the one that changed the public API. A cheap gate catches
two, you answer, and plan blocks on the third anyway: two round trips
instead of one, which costs a human more than the tokens it saves. So a
spec failing one or two of the three goes to plan, and the prompt says why.

The prompt tests now compare against whitespace-collapsed text, because
every phrase worth asserting on straddles a line break and writing the
assertion around the wrapping breaks the moment anyone rewraps a paragraph.

469 runs, 1597 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/reference is for rules you follow; docs/superpowers holds specs and
plans. Neither fits an investigation or a contract for something nobody has
built yet, so those two were filed as reference or left loose in tmp/.

docs/notes/ takes them: nothing in it is binding.

- admin-ui-frontend.md moves out of reference. It is the contract Plan 4
  will build against, not a rule anyone follows today.
- 2026-08-13-agent-convergence-strategies.md moves out of tmp/, where it
  was one disk failure from gone.

One reference updated in the design doc, and the README gains a section so
the directory is discoverable rather than folklore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The poller hit the GraphQL limit during the rehearsal, logged it, backed
off and stayed alive — which is the supervising loop working. But it backed
off with the same exponential curve it uses for everything else, capped at
five minutes, and a GraphQL window can be forty away. That is eight more
attempts failing for a reason mill already knew.

A rate limit is the one failure that says exactly when to try again, so
guessing is strictly worse. Mill::Github#rate_limit_reset asks; the endpoint
is itself exempt, so it costs nothing. The wait is bounded at both ends —
never shorter than a tick in case the reset just passed, never longer than
an hour in case the clocks disagree — and falls back to the old cap when
GitHub will not say.

The design already had this rule for stages: a rate-limited stage is waiting
rather than working, and its deadlines stop counting. This is the same rule
for mill own API access, which was the half that had none.

The default tick goes from 30 seconds to 60. The board is a queue you touch
by hand, so a minute costs nothing in responsiveness and halves what mill
spends against a budget measured in points rather than calls.

One Github instance is now built by Workers and shared with the board, the
supervisor and the poller, which is the same reason the supervisor and board
are shared: three of anything is three sets of state to disagree.

Caveat on the measurement that prompted this: monitors polling the board
every 25 seconds ran alongside the poller for two hours, so the exhausted
budget says little about what mill costs unattended. That number needs a
night with nobody watching.

474 runs, 1603 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where this stands flips five rows: the poller, the supervisor, board
writes, secrets injection, and the web UI boot path. Each says what is
built and what is still missing rather than just "built" — the supervisor
has no power assertion, secrets injection has never met a repo that
declares any, and the UI is a boot path with one route.

The rehearsal record sits beside Plan 2 in the build order. Two pull
requests nobody opened by hand, a crash test that recovered twice from an
orphaned process group, and nineteen and a half hours unattended overnight
surviving eight transient API failures.

And the finding worth more than the code: ten defects, six in that day own
work, none of which the fixture suite could have caught. Four were the same
shape — each component correct alone, each tested alone, the defect in the
handoff between two of them.

The lesson is recorded where the next plan will read it. An adversarial
review of this plan code, before any of it existed, found twelve defects
including four that would have stopped the factory silently. It caught that
two supervisor instances would make the reaper kill healthy stages. The fix
was to share one instance — and the shared instance was built without a
board. Reviews catch the layer they are looking at; only running the
assembled thing catches the seam below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The five-hour window closed mid-run and mill charged plan a strike for it.
That breaks a stated safety invariant — never charge a strike for something
the machine did to a stage — and the design says in as many words that a
rate-limited stage is waiting rather than working.

The plumbing existed at both ends and nothing joined them, which is the
fourth time this branch has found that shape. Mill::Stream already parsed
rate_limit_event and exposed rate_limited?. Mill::Ledger::COST already had
rate_limited at zero attempts and zero strikes. But Attempt had no delegate
and classify never asked, so a refused launch — which exits non-zero — fell
through to "return :crashed unless result.success?" and paid.

classify now checks it first, ahead of a failed resume, because mill never
got far enough to try the session.

The runner would then have hot-looped: :rate_limited fell to the generic
branch and re-ran immediately, hammering a door that does not open for
hours. It now waits for the window the CLI named in resetsAt, which Stream
keeps for that purpose — bounded at a minute in case the reset just passed
and at an hour in case the clocks disagree, and falling back to the hour
when the CLI does not say. Waiting in the run thread is correct rather than
lazy: the run is waiting, not working, and the supervisor leaves a run alone
while its thread is alive.

Free is not unlimited, and a refused launch inserts no row, so there is no
counter in the database to bound it. The cap is held in the runner: four
waits, then the run blocks and says plainly that nothing was charged.

Found because the boundary suite could not run — it hit the same limit, and
three of its tests failed rather than passing on an empty transcript, which
is the silence-is-never-success rule holding.

483 runs, 1617 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of this branch found four tests that pass for reasons
having nothing to do with what they claim to assert. Each sits on a real
bug, and each reported green while that bug shipped.

test_a_failing_callback_does_not_orphan_the_process_group passed only
because this machine can read kern.boottime. Where the host cannot,
announce_spawn's rescue calls Spawn.reap, which returns :unknown_boot
without signalling — so the group is orphaned and the raise parks behind the
child for thirty seconds. The new test forces boot_time to nil and asserts
both halves. It stubs on the test thread rather than inside the worker: the
worker is the thing that may hang, and a restore that hangs with it would
leave every later test in the process reading a nil boot time.

test_an_interrupted_run_is_started_again passed only because the default cap
of two left headroom for its single run. At MILL_CONCURRENCY=1 the
interrupted run's own running row fills the cap and restart's at_cap? check
refuses to re-enter it. That guard counts the run being restarted against
itself, so it can only ever refuse — it never has capacity to protect.

The fail_event compensation test used a fake whose start raised before
touching the run row, so it proved nothing about the real supervisor, whose
resumed flips the row to running and only then tells the board. It now
drives the real Mill::Supervisor with a board that raises the way a
misconfigured project does.

Every rate-limit test paired rate_limited: true with success: false, so none
could see that classify reads the flag before result.success?. A stage
throttled at minute two that recovers and exits zero with a valid verdict
has its work discarded, and because that path inserts no row the relaunch
reuses the log filename and destroys the successful run's log. The review
called that flag sticky; it is not — an allowed heartbeat clears it. The
reachable case is a refusal that is the last rate-limit event before the
result line arrives.

The first rewrite of the fail_event test was itself a lie of the same kind:
its fake hard-coded the bug's current location, so it would have stayed red
under a correct supervisor-side fix and a later session would have concluded
the fix had not worked. A fresh reviewer caught it. It now asserts the run
is left where a retry can find it, and both candidate repair sites were
applied and confirmed to turn it green.

The signalling invariant is scoped to stored pgids, because the spawn test
otherwise asks for what it forbids. The rule exists for a pgid read back
from the database, which may have crossed a reboot; a group this process
spawned and still holds the handle for cannot have. Spawn.reap's boot gate
is untouched and test_hostile_input still pins it at :unknown_boot.

All four are red on purpose, and CI stays red until the bugs beneath them
are fixed. Each has a verified fix recorded in the triage note. 485 runs,
1621 assertions, 4 failures — the four, and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four hostile reviewers went over this branch and reported 57 findings, raw
and untriaged, in a gitignored scratch file. A fourteen-session work queue
should not live somewhere a clean checkout loses.

The eight CRITICAL findings are six bugs. Three reviewers independently
found the start/reap two-walker race and two found the rate-limit
misclassification, which is the strongest signal in the set and was
invisible while the findings sat in a flat list.

It also records the blocker none of the reviewers could see, because all
four ran on this laptop: CI has never been green on this branch. Six
TestRepo tests error on a clean runner with "empty ident name", because
commit_to_base commits inside a clone that Repo.prepare made with a real
git clone and nothing sets an identity there. It passes here only because
the author's global gitconfig supplies one — the same shape as the four
tests in the previous commit, and the reason it is first in the queue.

Twenty HIGH findings are split into eight that block a merge and twelve that
do not. Two cases where the obvious fix quietly decides something nobody has
decided are recorded as questions rather than bugs, so they are not settled
by accident while fixing something adjacent.

The suggested order puts the dead reaper third. A reaper that aborts its
sweep every tick hides every other symptom, and both the claim orphan and
the failed-start bug produce exactly the row that kills it.

Merging before the first ten means merging something not safe to run
unattended: Workers.enabled? defaults to on, so a stray Ready on the board
reaches every critical path in the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant