Plan 3a — autonomy: the poller, the supervisor, and board writes - #1
Open
slowernet wants to merge 26 commits into
Open
Plan 3a — autonomy: the poller, the supervisor, and board writes#1slowernet wants to merge 26 commits into
slowernet wants to merge 26 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Set Status to
Ready, walk away, come back to a pull request.Mill::Pollerreconciles the board and sweeps comments behind a transactionalcursor.
Mill::Supervisorprepares repos, claims to a cap, owns the worktreelifecycle, walks each run in its own thread, and reaps against a verified
process identity.
Mill::Boardwrites Status and re-drives a write that neverlanded.
Mill::Secretsinjects a repo's environment.app.rbandconfig.rugive 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 atplanwith three questions, answered in acomment, resumed on its own session, finished. Zero strikes: asking is free.
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.
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 realCLI. Required because this branch changed
Mill::SpawnandMill::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::Workersassembled a supervisor without a board, so mill would have runthe 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 beenresumed.
Supervisor#walkreturned a database row wherefinishexpected the runner'sstate, so the first real block posted
Blocked at: .`` to the issue. millasked three good questions and threw all of them away.
blocked → runningtransition, leaving a resumed runinvisible to the reaper for the rest of its route.
strike, which breaks a stated safety invariant.
Mill::Streamalreadydetected it and
COST[:rate_limited]already priced it at zero;classifynever 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-jsonit parses back, given a shortvalue like
DEBUG=true.Two runbook steps also turned out not to work as written — the built-in
Statusfield 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::Supervisorinstances would make the reaper kill healthystages. 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 thelog reaper are Plan 3b. The UI's routes are Plan 4. The
fastanditerateroutes 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