ONE Vibe Coding Can't Fail, So It Can't Learn - ONE Blog
• ONE

Vibe Coding Can't Fail, So It Can't Learn

Our self-learning system ran for months without learning anything, and every dashboard said it was fine. What that taught us about building with AI: the engineer's job is the part of the loop that can say no. Two fields, six verbs, a checker that never grades its own work, real costs, and what still doesn't work.

engineering ai-agents software-factory vibe-coding testing

In July we went looking for proof that our routing system learned. It was supposed to be the core of the product. Every time a piece of work went well, the path to whoever did it would get a little stronger. Every failure would add resistance. Quiet paths would decay. We had the formula written down months before most of the code:

 weight = 1 + max(0, strength − resistance) × sensitivity

When we finally checked, not one weight had ever moved. Not once, in the whole life of the system.

There were three causes, and none of them was interesting. The web layer was using a stale API key, so every write to the database came back 401. The function making those writes caught every error and returned an empty array, because at some point that had seemed friendlier than crashing. And the query language had changed under us, so the syntax we used to increment a number wasn’t valid any more. Creating a path worked. Strengthening one never did.

The worst part was that the dashboards looked alive the whole time. A mirror copy of the weights in a second database kept updating, so every chart moved. The system fired on every signal and remembered nothing.

A stale key, a catch block and a line of syntax. Any one of them would have been caught by a single check that asked “did the number actually go up?” Nothing in our loop asked.

I’ve thought about that a lot since, because it’s the clearest example I have of what’s wrong with how most people build with AI right now. It wasn’t that the code was bad. The code was mostly fine. It was that nothing in the loop could say no.


Where is the output?

Jonathan Blow, who made Braid and The Witness, was asked about AI and code in a recent interview titled “If AI Solved Coding, Why Is My Software Still the Same”. He asked a question every team using these tools should be able to answer:

“If productivity has gone up, where is the output?”

He did the arithmetic. If AI makes programmers ten times faster and that started three years ago, we should have seen thirty years of software progress. He looks at the software he uses and sees roughly none. Same bugs, same windows.

He also said this, which I think is exactly right:

“If you actually could do what everybody says AIs can do with code, that would just be probably the most valuable company in the world.”

Blow is a skeptic and I’m not claiming he’d agree with anything here. I’m quoting him because his objections are the good ones.

So let me concede the obvious one first. On our dev branch there are 14,473 commits since 2026-06-04, and 5,545 of them came after October 1st. There are 2,161 markdown files in our text/ folder. If you wanted a picture of AI producing a mountain, that’s a decent one. Blow would say a mountain isn’t output, and he’d be right:

“It’ll always generate more code, and it’ll generate way more code than a human programmer.”

Commits measure activity. So do lines and documents. The pile is exactly the kind of buildup he describes when he talks about how operating systems got to where they are: adding is easy, and nobody wants to be the one who cleans up.

So this isn’t a post about how much came out. It’s about what stood in the way of the things that shouldn’t have.


The thirty-line problem

Blow describes a time AI helped him. He needed to color some cells in a spreadsheet, which meant Visual Basic, a language he never learned. The model gave him about thirty lines that worked. It took an hour instead of five, and he threw the code away afterwards.

That’s a good use, and it’s where vibe coding is genuinely fine. His definition is the plain one: you don’t look at the code. For thirty lines you’ll delete tomorrow, why would you? If it’s wrong you find out in seconds.

The trouble is that nothing tells you when you’ve left that zone. Thirty lines worked, so you ask for three hundred, then three thousand. Somewhere along the way the program stops being text and becomes a set of agreements between its parts: this piece expects data in this shape, that one has to run first, this other one can’t take more than a second. Nothing in prompt-and-paste checks any of those.

Blow draws it as a graph. Each piece of the program is a dot, and each line between two dots is something one piece assumes about another. A good programmer keeps that map in their head and refuses changes that break it. The number of possible lines grows much faster than the number of dots, n × (n − 1) ÷ 2:

 pieces   lines that could exist between them
 ──────   ────────────────────────────────────
      4                                       6
     10                                      45
    100                                   4,950
  1,000                                 499,500

Nobody holds 4,950 assumptions in their head, and neither does a model. Blow’s description of what the model does instead is the most useful one I’ve read:

“I’m very specifically lately thinking about it as a search algorithm that kind of gets what you mean.”

That’s a good tool. It found his thirty lines. But he also says it isn’t good at judgment calls, and every line in that graph is a judgment call. The model will explain each of its choices in a reasonable-sounding paragraph. In his experience the paragraphs are fine and the choices aren’t.


A loop that can’t say no

Here’s the vibe coding loop. You write a prompt. Code shows up. It looks right. You ship.

There’s no step in that sequence where the answer can be no. The only judge is you, reading code you didn’t write, at the speed a machine writes it, and you get tired long before it does. That’s not a criticism of the model. A loop where nothing can fail can’t learn anything, whoever is typing.

 VIBE CODING                     ENGINEERING
 ───────────                     ───────────
 a prompt                        a check you can run
   │                               │  passes or fails
   ▼                               ▼
 code appears                    code appears
   │                               │
   ▼                               ▼
 it looks right                  something that didn't write it
   │                               │  runs the check
   ▼                               ▼
 ship                            pass: ship. fail: try again.
                                 couldn't run: ask someone else.

Blow defines an engineer as “somebody who understands constraints imposed by reality” and designs with them as input. Vibe coding takes the constraints out of the loop. That’s all the right-hand column puts back.

So if the model writes the code, what’s the engineer for? I think it comes down to this. You write down how you’ll know it’s done, before there’s any code. You make sure whatever checks the work isn’t the thing that made it. And you make sure every result, good or bad, gets recorded somewhere it can change what happens next. That last one is what our July bug was missing, and it’s the part I want to spend the most time on.


The whole idea fits in about a hundred lines

We keep a file in our repo called text/100-lines.md. It’s a distillation of the system, roughly a hundred lines of schema and a hundred of engine. The production version is bigger and runs on TypeDB and D1, but the shape is the same. The repo isn’t public, so the excerpt below is the part to take, and I think it’s worth taking whether or not you ever use anything of ours.

One thing moves through the system, and it has two fields:

 signal = { receiver, data }

   receiver   where it's going    "scout" or "scout:observe"
   data       what it carries     anything, or nothing

A request is a signal. A finished task is a signal. A test result and a payment are signals. Send one to a receiver that doesn’t exist and it dissolves; nothing crashes.

On top of that there are six verbs, and we don’t add more:

 verb     what it does                  what it changes
 ───────  ────────────────────────────  ──────────────────────
 signal   send work to a receiver       adds a row to the log
 mark     "that worked"                 path strength goes up
 warn     "that failed"                 path resistance goes up
 fade     everything decays over time   both numbers shrink
 follow   pick the strongest safe trail nothing (it's a read)
 harden   pin what has proven out       writes a hypothesis down

A path is a connection between two things: a kind of task and the model that builds it, say, or one workflow step and the next. Every path carries two numbers. Here’s the core of it, trimmed and lightly simplified from that file:

const strength: Record<string, number> = {}    // what worked
const resistance: Record<string, number> = {}  // what failed

const mark = (path: string, w = 1) =>
  (strength[path] = (strength[path] || 0) + w)

const warn = (path: string, w = 1) =>
  (resistance[path] = (resistance[path] || 0) + w)

const status = (path: string) => {
  const s = strength[path] || 0, r = resistance[path] || 0
  return r > s && r >= 10 ? 'toxic'
    : s >= 50 ? 'highway'
    : s > 0 && s < 5 ? 'fading'
    : 'active'
}

// time passes: everything decays, success and failure alike
const fade = (rate = 0.1) =>
  [strength, resistance].forEach(m =>
    Object.keys(m).forEach(e => {
      m[e] *= 1 - rate
      if (m[e] < 0.01) delete m[e]          // forgotten
    }))

// route by what worked before, never into a toxic path
const follow = (type?: string) =>
  Object.entries(strength)
    .filter(([e]) => (!type || e.includes(type))
                     && status(e) !== 'toxic')
    .sort(([, a], [, b]) => b - a)[0]?.[0] ?? null

That’s most of it. There’s no router table and no central scheduler, just two counters per path and time.

You need both counters. A path that only remembers success can never learn to avoid anything. Fade is the one people skip, and I think it matters most: evidence expires. Something that worked once, a long time ago, doesn’t get to steer you forever. In production each group sets its own rate, and the 5 percent default is why a path sitting at 10 reads 9.5 the next day.

Harden is the odd one. In this toy version it pins a path so fade can’t take it. In production it writes a hypothesis down instead: a statement with a confidence, a p-value and a count of how many observations formed it. That’s the one place where a drifting number becomes a fact somebody can read.

And there’s one rule on top that I’d adopt anywhere: every signal closes its loop. It ends in a mark, a warn, or a dissolve. No silent returns. Our July bug was a silent return. The catch block that turned a 401 into an empty array was precisely the thing this rule forbids.

After the fix, we tested the loop in production on purpose. We seeded a path at strength 5, sent two real signals through it and read back 7. More work, 8. The daily decay took a path at 10 to 9.5. Then we poisoned a path with three heavy failures in a row, and the router stopped sending work there and escalated the next signal to a person. That was the first time we watched it do what we’d been claiming.

One design choice falls out of this that I didn’t expect to like as much as I do. Because everything is a signal to a named receiver, every way into the system opens onto the same names: the SDK, the HTTP routes, the CLI, the website, and an MCP server for Claude and Cursor. The MCP server doesn’t grow a new tool for every feature. It has generic signal and ask tools that can reach any receiver by name, so the receiver namespace is the tool catalog. Add a capability once and every client can already reach it.


Who checks the checker

The counters are only as honest as whatever decides between mark and warn. So the part of our build system I’d defend hardest is the checker.

Work comes in as an idea, gets written up as a promise with one proof attached, and is split into tasks, each with its own check written before any code. Every task is one of eight fixed kinds:

 an idea
   │  written up as a promise, with one check that proves it
   ▼
 tasks on a board, each with its own check
   │
   ▼
 one of eight kinds of work
   card · page · write · secure   cheap models build these
   decide · operate               wait for a person
   recon                          splits into more tasks
   rollup                         closed out by a script
   │
   ▼
 a checker that isn't the builder
   │
   ├─► passed      mark the path that built it
   ├─► failed      warn it, and the task retries
   └─► couldn't    ask the next checker up, change nothing
       check

If the classifier can’t place a task, it leaves it for a person rather than guessing. decide and operate tasks always wait for a person, because a decision isn’t a build step. The list of eight lives in one file, one.ie/web/src/lib/factory/classify.ts, and a test fails if the front-door command, the spec and the executor ever disagree about it.

Every step also says which kind of checker may judge it, and the cheapest one that can say no goes first:

 level  checker      what it does             can't check when
 ─────  ───────────  ───────────────────────  ────────────────────
  0     exit code    runs a test, a build     the test won't run
  1     decide       answers one typed Q      it isn't sure enough
  2     model        reads the diff           it times out
  3     panel        two families must agree  they disagree
  4     person       has the last word        (task stays open)

Two rules make it a ladder rather than a list.

A pass or a fail stops where it is. Nobody asks a more expensive checker to overrule a test. The test was the cheapest thing that could say no, and it said it.

“Couldn’t check” moves up exactly one level, and it moves nothing. A test that won’t start, a model that times out, a panel that splits: none of those is a pass or a fail. So the task climbs a level, we record what was unreachable, and no path gets a mark or a warn. If the gateway is down and every check comes back empty, the worst thing you can do is read empty as success. That’s our July bug again, in a different place.

A panel isn’t an average, either. It needs agreement from at least two different model families, and two models from the same family count as one. Five models trained on the same data agreeing is one opinion said five times. The same goes for tests: the builder is never the only one writing them, and no two builders on a task come from the same family.

Each of these rules has its own test file under one.ie/web/tests/unit/factory/: judge-ladder.test.ts, panel-verdict.test.ts, could-not-look-build.test.ts and step-judge-named.test.ts.


What it costs

Once there’s a checker, model choice stops being a matter of taste and becomes a measurement. We call a measurement run a bake: a small, well-defined task with a fixed test, given to every model. bash .claude/scripts/bake.sh --report prints who passed and what it cost.

On our react-component card, 22 models ran and 15 passed. Here’s the cost of one passing answer for four of them, on a log scale because the range is over 400 to 1:

 deepseek-v4-flash  ▓▓▓▓▓▓                          $0.00030
 haiku-4.5          ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓               $0.0028
 sonnet-5           ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓        $0.0094
 opus (sub-equiv)   ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ $0.049
 ▓ invoiced by the gateway   ▒ list-equivalent, not invoiced
 (log scale, one decade is 12 characters, base $0.0001)
 source: bake.sh --report · react-component card · asOf 2026-11-14

Opus passed too, at roughly 160 times the cost of the cheapest invoiced pass. Those are two different ways of pricing, so read that as a couple of orders of magnitude, not a quote.

I want to keep this narrow. A card is a few hundred lines of context and a test that decides. For work shaped like that, cheap models pass, and a system that routes each kind of task to the cheapest model that keeps passing spends a fraction of a cent where a long chat session spends cents. Each verdict is a mark or a warn on the path from that kind of task to that model, so the router is built to take work away from a model that stops passing, without anyone editing a config. That’s the design, with a test (pick-by-price.test.ts) behind it; whether it beats a fixed choice over months is a separate question, and I come back to it at the end.

For work with no check you can run, this table tells you nothing, because there’s nothing to measure. That’s really the economic argument. Cheap models are usable when there’s a checker. Without one, you pay for the expensive model because you can’t tell whether the cheap one got it right.


When the evidence lies

A learning system learns whatever its evidence says, so the evidence is where the bugs that matter live. The July bug was the evidence not arriving at all. We’ve since found it arriving wrong, twice.

A few days after the July fix we found that clicking Test on a workflow ran it exactly like a real trigger. Every debugging click deposited a mark or a warn on the paths it crossed. Ten test clicks while you fixed a typo could out-vote your first three real customers, and nothing said so. The fix was small: a test run still executes every step for real, but deposits nothing.

The second one happened on 2026-11-12. Our server build has a size limit. It went over: 36,989,373 bytes, 86,981 more than allowed. We got it back under by stopping two graph canvases from rendering on the server, which took out 313 KB, and then lowered the limit by hand to 36,676,159.

The trap was in the check, not the fix. In an agent’s shell, under the test runner, bun wasn’t on the PATH. The build exited with code 127 and left the previous build sitting in dist/server. The size check measured the old build and reported a pass.

 build step        dist/server          size check
 ────────────────  ───────────────────  ──────────
 bun not on PATH   the old build stays  reads it
 exit 127          (stale mtime)        passes

A check that passes without checking anything is worse than no check, because people trust it. Now we look at the modification time of the thing we’re measuring before we believe the number.

Those three bugs have the same shape. The system’s evidence was wrong and every surface still looked healthy. That’s Blow’s point about reasoning, turned on us: a system that sounds right and isn’t is more dangerous than one that’s obviously broken.


What it’s actually like

People ask whether running this feels like coding. It doesn’t. It feels like reading verdicts and writing the sentence that tells a machine what “done” means. Mostly, it feels like waiting.

On 2026-11-12 we measured why agents felt slow. Six Opus sessions on high effort, over thirty minutes. Tool calls took about 3 percent of wall time. The rest was waiting on the model. 26 of 196 replies took more than a minute, mostly 230 to 260 seconds for 150 to 750 tokens of output, and the stalls hit all sessions at the same moment, so the cause was upstream and not on the machine.

 where 30 minutes went (six Opus sessions, 196 replies)
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░░  93% model wait
 ▓ model wait  ▒ tools (about 3%)  ░ everything else
 source: session transcripts, 2026-11-12 08:20 to 08:50

When things go wrong, they’re seldom wrong algorithms. They’re agents stepping on each other. The same day, one agent hit “cannot pull with rebase: you have unstaged changes” and ran git rebase --autostash. That swept 21 files of other agents’ unfinished work into a shared stash, and restoring it only half worked. We got it back by hash. Now nobody stashes on the shared tree, and you pull only after your own files are committed. Concurrent work needs the same manners whether the workers are people or not.

And agents can’t grade themselves. When a performance agent tried to record its own result, the server refused with a 403 self_deal. The guard dates from the same week as the July fix, when a security review found that learned weights were global, so one customer’s feedback could in principle poison another’s routing. Two agent sessions were working in the repo at the time and neither knew about the other. One scoped every weight to the workspace that earned it. The other blocked self-dealing on the write endpoints. The two fixes merged without a conflict, and together they’re stronger than either alone.

So the job, day to day, is reading three kinds of answer: passed, failed, and couldn’t check. Most of the skill is noticing which of those the system might be lying about.


How to be an AI engineer

None of this needs our system. Each of these works for a team of three with a CI server.

  1. Write the check before the code. A command that passes or fails. If you can’t write it, the task isn’t ready, and a better prompt won’t fix that.

  2. Never let the maker grade its own work. A different model family for the tests than for the code, or better, a plain deterministic test. Use the cheapest checker that can say no, first.

  3. Give “couldn’t check” its own answer. A timeout is neither pass nor fail. Give it a third exit code, and make it move nothing. Systems without a third answer end up treating silence as success.

  4. Close every loop. Every unit of work ends in a recorded pass, a recorded fail, or a recorded “nobody was there.” Grep your codebase for catch blocks that return an empty value. That’s where our learning went for months.

  5. Check that the number moved. After any write that matters, read it back. Our whole July bug would have died on one assertion that said strength went from 5 to 6.

  6. Don’t let experiments vote. Test runs, debugging clicks and dry runs should execute for real and record nothing. Check this before trusting any score your own tools produce.

  7. Keep two counters and let both fade. Success and failure, both decaying. Old evidence should expire. Write a belief down as a fact only when it has earned it.

  8. Measure the model on your own tasks, by invoice. Not on a leaderboard. 15 of 22 passed one of our cards. Your number is something you have to run.

  9. Read the diff of anything you’ll keep, and schedule the cleanup. Models write far more code than people do, and every extra line is something to understand or delete. Blow stops to pay down debt in his own projects: a week of cleanup and no new features. A model will never volunteer for that week, so put it on the calendar.

  10. Write down why. Who asked, when, in their words. Every plan in our repo starts with that block before anything is designed. In six months someone will need to know whether a constraint is holding something up.

If you only do three, do 1, 2 and 4.


What still doesn’t work

A post arguing for honest checkers should say where its own are weak.

The commit count still doesn’t answer Blow. I can count the checks that stopped things, but I haven’t measured thirty years of progress and I wouldn’t claim to. If you want to hold us to his test, look at what ships and what breaks, not at the log.

I don’t yet have a published number showing that the learned weights route better than a flat baseline. I can show the weights moving and the router refusing a poisoned path. That’s evidence the mechanism works, not evidence the results are better, and those aren’t the same claim.

The checks only cover work that has a check. Work without one falls through to a model reading a model’s output, which is still a weak judge.

The cost table is one card, one kind of task, a prompt of about 1,300 tokens. It says nothing about a long refactor across a large system, which is exactly what Blow cares about and where we have the least data. Some cheap-model prices in that ledger are still list prices, not invoices.

We have our own pile. 2,161 documents is a lot, each one says where it came from, and nobody has paid it down. By the argument of this post, that’s debt we haven’t scheduled.

And 93 percent of the agent time we measured was spent waiting on someone else’s servers.

Blow said that if you could really do what people claim AI can do with code, it would be the most valuable company in the world. We haven’t done that. What we have is smaller and easier to check: a loop where work can fail, a record of every time it did, and numbers that only move when something real happened. For months we didn’t even have that, and every dashboard said we did.