All articles

Agent profiles, part five: what a green run still gets wrong

Every lane passed its acceptance check, the gate came back green, and the wave still shipped something wrong. The failures that survive a working process, and what each one cost.

Part four covered the model: the goal file, the run contract, roles instead of model names, and what a lane brief has to carry. This part is the other half, and it is the half worth reading if you already have a process that works.

The document’s longest section catalogues failures sharing one shape: every check passed, the gate was green, and the run still shipped something wrong. A few generalise well beyond where they were found.

The one file every lane has to edit

One file, one owner handles most contention and does nothing at all for the single append-only function every lane must add an entry to: a migration registrar, a dependency-injection container, a route table, a plugin list. Those are one file by construction, so assigning it to a single owner just creates a queue. The tempting fix, having each lane state its entry in its report and letting the root apply them all at integration, fails an unattended run: a lane that cannot register its own entry cannot exercise its own code, so it either sits blocked for hours or validates against a state that does not exist yet, and both failures are silent overnight.

The fix is to split the registry before fan-out: the root creates one empty stub file per lane, each exporting a single registration function frozen in the goal; reduces the shared file to a call list invoking those functions in fixed order and never touches it again; and pre-assigns every ordering-sensitive identifier, migration numbers, route paths, permission names, in a table in the goal. Each lane then owns exactly one file, is testable on its own, and blocks nobody. A lane wanting a different identifier stops and says so, because pre-assigning them was the point.

The fix has a trap in it. Assigning an identifier to a lane in the table is not the same as assigning the work of implementing it. The owned-files list does that, and it is the line that gets it wrong. One wave pre-assigned both halves of a new read surface, a route and its handler, to a lane’s identifier row, then wrote that lane’s ownership as only its front-end files plus a registration stub. Nothing owned the server-side handlers. Every lane passed its own acceptance check, the gate was green, and the feature shipped as a truthful unavailable page: the route existed, resolved correctly, and told the user honestly that the thing it pointed to did not exist, because nobody had been given the file that would have implemented it. Nobody found this from the run itself; it sat there until a person opened the console and saw the page. The rule that followed: cross-check the identifier table against each lane’s owned-files line. A route is two files, not one, when server and client are written in different languages, and both must appear.

Live from the protocolAppend-only registries — the contention case one-owner does not solveRevision a9ce5e3, updated

One file, one owner handles files a lane can own outright. It does not handle the single registry function every lane must append to: a migration registrar, a dependency-injection container, a route or command table, a plugin list, a generated manifest. Those are one file by construction, so assigning them to the root creates a queue.

The tempting answer — lanes state their entry in their report and the root applies them all at integration — is wrong for an unattended run. A lane that cannot register its own entry cannot exercise its own code, so it either sits blocked for hours or validates against a state that does not exist. Both fail quietly overnight.

Split the registry before fan-out instead. In the pre-fan-out pass the root:

  1. Creates one empty stub file per lane, each exporting a single registration function whose name is frozen in the goal.
  2. Reduces the shared file to a call list invoking those functions in a frozen order, and never edits it again.
  3. Pre-assigns every ordering-sensitive identifier in a table in the goal — migration numbers or names, route paths, capability or permission names, generated-artifact keys.

Each lane then owns exactly one file, is testable in isolation, and blocks nobody. A lane that wants an identifier other than its assigned one stops and says so rather than choosing its own; that is the point of pre-assigning them.

Assigning an identifier to a lane does not assign the work to it — the owned-files list does, and that is the line that gets it wrong. One wave pre-assigned both halves of a new read surface to a lane in the identifier table, then wrote that lane’s ownership as its own front-end files and a registration stub. Nothing owned the server handlers. Every lane passed its acceptance check, the gate was green, and the feature shipped as a truthful “unavailable” page — the gap stayed invisible until a human opened the console. Cross-check the identifier table against the owned-files line of the lane it names: if a row assigns a route, a migration or a generated key, that lane’s ownership must include the file that implements it, in every repository and every language the identifier touches. A route is two files when the server and the client are written in different languages.

Where a digest, lockfile or checksum covers the whole registry, it belongs to the integration pass and is regenerated exactly once, at the end.

A shared evidence document is a registry too — but splitting it is the wrong fix. When several lanes each produce a number, a finding or a row for one results document, the per-lane stub pattern above produces a shredded document nobody can read. Give the document one owner, scheduled last, with real declared dependencies on the lanes that feed it. That owner takes the others’ figures as inputs and writes the whole thing once. It is a deliberate exception to “a lane writes its own evidence”, so say in the goal that it is one and say why, or the late owner reads as an accidental bottleneck.

When the project has no released users and no persisted state to preserve, prefer collapsing the registry to a single fresh baseline over extending it. A chain of increments nobody will ever replay is pure carrying cost. That licence is temporary — record it with its expiry, per §8.

Five lanes converging on one shared registry file, beside the same five lanes each owning a generated stub, with the pre-assignment table and the ownership trap below.

Splitting the file before fan-out is what makes one file, one owner true. The strip at the bottom is the trap inside the fix: an identifier row is not an ownership line.

Count the files before you count the lanes

Before fanning out a wave that adds several of the same kind of thing, count how many existing files adding just one forces you to touch, with a plain rg search, and put that number in the goal. One product with four data sources had 21 exhaustive four-way switches across 13 files. The next wave planned to add five sources at once. Under one-file-one-owner that is not slow, it is impossible: five lanes each needing the same 13 files either queue or collide, silently, overnight.

A second agent in the same checkout is a concurrency problem

Another agent, a human or a scheduled job working in the same checkout is a concurrency problem, not a merge problem. The hazard that actually loses work is git commit -a or git add -A sweeping someone else’s half-finished changes into your commit, attributed to your run, with neither side noticing. The fix is naming the concurrent party and its files in the launch message and forbidding the blanket flags, with pathspecs on both add and commit, since pathspecs on the add alone still leave a bare git commit free to sweep up the whole index.

Reachability needs its own acceptance check

These are two different claims, and a goal asking only for the first gets only the first. Two parsers were once proven against real multi-gigabyte archives, exact record counts and deduplication all correct, while the shipping application had exactly one entry point and it belonged to a third, unrelated source. Three waves of cross-source features were built on data no user could get into the app. The fix: put the entry point itself, the route, the menu item, the command that reaches it, into the acceptance check.

Live from the protocolProven at scale is not shipped — put reachability in the acceptance checkRevision a9ce5e3, updated

A lane can prove an engine against a real artifact at full scale, commit it green, and leave it unreachable from the product. Observed: two parsers were tested against real multi-gigabyte archives, with exact record counts and second-import dedup proven, while the shipping app had exactly one entry point and it belonged to a third source. Three waves of cross-source features were built on top of data that no user could ever get into the app.

Correctness of the mechanism and reachability of the feature are different claims, and a goal that only asks for the first will get only the first. Where a lane builds something a user is meant to reach, make the acceptance check name the entry point — the route, the menu item, the command, the picker — and require evidence that it resolves. “It compiles and its tests pass” is not evidence that anyone can get to it.

The completion event proves the review ran

A wave is exactly the size at which that bites. The review gate I run before committing exits zero whether it found twelve problems or nothing at all, so the exit status is not the signal; the findings are, and a run with no completion event is a failed review rather than a clean one. Past roughly a hundred changed files the tool drops its socket during the connect phase and analyses nothing, while its own doctor command passes every check including the one that probes that socket, and a single-file review against the same account completes in the same minute. A fan-out wave routinely changes more than a hundred files, so the review gets split along the line the rule already draws - generated artefacts, goldens and documentation are outside the gate anyway and are usually most of the bytes - and every changed source file has to appear in some completed review. The cost of slicing is that a finding raised against a file you excluded is a false positive, because the reviewer is reading a tree where that file was never updated.

Restore parked work by patch identity

A stash of validated work gets restored with apply, never pop, because pop deletes it the instant it succeeds, leaving nothing to fall back to if the next step goes wrong; the later run recomputes the patch id first and stops if it does not match what the goal froze. This exists because it was needed once for real: two runs of security-reviewed work had nothing left to recover after a pop succeeded and the next step did not.

The report is a terminal action

The single most common reason a good run produces a bad handoff is that the report gets specified as a format and never as a trigger. An agent will happily satisfy a report template if asked for it. Left alone, it finishes the last lane, decides the work is done, and stops, leaving the operator to type “give me a full summary of the whole run” the next morning, a question they should never have had to ask. Stating the report’s structure once, near the end of a long goal, reliably fails to produce a report, because that section is exactly what compaction and momentum erase first.

So the instruction appears twice: in the run contract, read first, and again in the report section, that producing the report is the last unit of work, not a response to a request. The report path is also named in the launch message, because that is what the agent is holding at the exact moment it finishes the last lane. One run carried the entire report section correctly and closed its launch message with “write the final report to the structure in section 12”. It produced a long, well-structured report, in chat, with no file: naming a structure asks for a shape, naming a path asks for an artefact.

A real one of these exists publicly: a run-end report from a finished operational-readiness wave on synthkit, a repository of mine. It states the campaign outcome in its first paragraph, then walks through failed release attempts kept as evidence rather than rewritten, exact image digests and CI run IDs, a live rollback-and-forward exercise on a standing host, and a closing section naming which checks ran and which were skipped. Nothing says “as described above”: it is written for someone who never saw the run happen, because that is exactly who reads it.

An always-loaded rule beats a document you have to open

The report failure has a sibling that is worse, because it ran for months without anybody noticing.

My own always-loaded rules and the fan-out protocol disagreed about the launch message. The protocol says a campaign’s launch message is a file on disk beside the goal. The rule file, loaded automatically into every single turn of every session, listed the artefacts in a way that read as though the launch message were a chat prompt. Nobody reconciled them, because there was no moment at which both were on screen at once.

The always-loaded wording won every time. The count is the part worth carrying: 85 goal files, and the last launch file written was for wave 47. Forty-five consecutive waves produced a goal and no launch artefact, and each of those runs looked entirely normal from the inside.

The general shape of that is worth more than the specific fix. Something re-sent on every turn will quietly outrank something that has to be deliberately opened, however authoritative the opened document is, and the contradiction never surfaces as a conflict. It surfaces as a habit. So when a standing rule and a reference document cover the same ground, the standing rule is the one to check first, and the check is a count of artefacts on disk rather than a reading of either text.

What they have in common

None of these were caught by the run that caused them. The switch count, the swept commit, the unreachable feature, the truncated review, the lost stash, the report that never got written and the launch file that stopped being written for forty-five waves: every one of them passed whatever check was in place at the time, and every one was found afterwards by a person looking at something else.

That is the argument for writing failures down as counters rather than as advice. “Be careful about shared files” changes nothing. “Count the existing files that adding one of these forces you to touch, with an rg search, and put that number in the goal” is a step somebody performs. The catalogue in the protocol is long and dull for exactly that reason: each entry is a failure that already happened once, reduced to the check that would have caught it.