On September 15 at 10:27 UTC, an agent working for me ran a dry-run seven times, once per language of our website, and created seven texts in the production content of our commercial site. I had said, in writing, at the start of the session: no writes, except with --dry-run. It used --dry-run. The texts are real.

Eleven seconds later the agent found out on its own. A minute after that it had stopped and written me a report. I read it at 14:27. Four hours.

What was at stake

I run engineering at GoodBarber, an app platform. This was a side task: rewriting the share previews of our website, the descriptions that show up when you paste a link into a chat. The agent has no access to the database. It goes through the site's text-editing API, the one our editors use, with the same rights they have. What it created were seven descriptions, one per language, exactly the ones it had been asked to prepare. Nothing was deleted or modified, no customer data was touched. When I read them, they were the texts I would have approved, and we kept them.

So, nothing serious. I'm writing it down anyway, because of what it shows once you put the times in a row, and because of something @pierrelaurentmedori , Marc and I do in the datacenter without thinking about it.

The times

UTC what happened
Sep 7, 14:09 last git pull of the repository holding our agents' tools, on the server where the agent runs
Sep 11, 10:52 a colleague commits a fix to the text-editing script. Its comment: "--dry-run update … silently sent a real PATCH"
Sep 15, 09:49 session starts. Instruction: no writes except with --dry-run
10:24 the agent runs the script's --help, then reads its dry-run code
10:27:52 the loop: 7 languages, one create each, with --dry-run
10:28:03 the agent counts DRY-RUN lines in its output: 0. It counts HTTP 201: 7
10:29 it stops everything. It deletes nothing, because a delete would be another write I hadn't approved
14:27 I read the report and ask why it stopped everything; a minute later it agrees nothing required it to stop the rest

What the flag did

The script declares its shared options once, in a parent parser, and passes that parent to the top-level parser and to every subcommand. It's a common way to let an option go anywhere on the command line. With Python's argparse it has a side effect: when the option comes before the subcommand, the top-level parser sets it to True, then the subcommand's parser applies its own default and sets it back to False. No warning.

common = argparse.ArgumentParser(add_help=False) common.add_argument('--dry-run', action='store_true') parser = argparse.ArgumentParser(parents=[common]) sub = parser.add_subparsers(dest='cmd') sub.add_parser('create', parents=[common]) parser.parse_args(['--dry-run', 'create']) # dry_run=False parser.parse_args(['create', '--dry-run']) # dry_run=True 

Checked on Python 3.13.5. The colleague's fix, four days earlier, builds the parent with argument_default=SUPPRESS, so an absent option sets nothing. It was on the remote, and our server had even fetched it, the day before and again that morning. The copy the agent ran had not been updated since September 7.

The line the agent actually wrote

This is the part I keep looking at. The agent had read the code three minutes earlier and wasn't sure where the flag had to go. So it covered both cases:

python3 site_texts.py --dry-run create --payload $l.json \ || python3 site_texts.py create --dry-run --payload $l.json 

Try the first form; if it fails, try the second. A reasonable hedge against a flag the parser rejects. Against a flag the parser ignores, it does nothing: the first form doesn't fail, it writes, and the fallback never runs. Seven times, because the loop had no reason to stop after the first language.

Then it did the right thing, just after instead of before: it counted what the script had said back. Seven HTTP 201 where it expected seven DRY-RUN.

"Unplug 21." "Unplugging 21."

When Pierre-Laurent (Pilo, when we talk), Marc and I work in the datacenter, the room is loud and the machines all look alike. Without ever deciding it, we ended up doing what every pilot knows: one says "unplug 21", the other says "unplugging 21", and only then does the hand move. In aviation it's called readback. The tower says "runway 34, cleared to land", you read back "runway 34, cleared to land" with your callsign, and the controller has to listen to what you said and correct it if it's wrong. That second half is the hearback, and a readback nobody listens to is just more noise on the frequency.

Pierre-Laurent has been writing about read-back from the side of the MCP server he runs: a 200 doesn't tell you the right thing happened, so you read the state back and only then call it done. That's checking the world, after the write. That morning, this part worked fine: the agent read the result back within eleven seconds.

What was missing is the datacenter part, before the hand moves. Three readbacks could have happened and didn't:

  • The tool never said back the mode. A dry-run prints DRY-RUN POST …; a real call prints HTTP 201. Both only after acting. One line before acting, mode: WRITE, and the loop reads it.
  • The loop never did one and listened. Seven identical calls are one instruction. Run one, read what comes back, then run the other six.
  • The tool's copy never said back its version. The agent was running a script from September 7. We pull a repository before changing its code. Nobody pulls the repository of a tool before using it.

And the hearback: I gave the instruction and listened four hours later. The agent stopped, and the stop cost a morning, because the only one who could listen wasn't listening.

What changed since, and what didn't

  • The copy of our agents' tools on that server was updated on September 21, six days after, ten days after the fix. Nothing updates it yet before a session uses it.
  • Our write scripts still write by default, and --dry-run is still the thing you have to remember.
  • The agent suggested the next step: point future dry-runs at an address that can't answer, so the network holds the dry-run instead of a flag. I haven't done it yet.
  • Nothing is set up to tell me when a session has stopped and is waiting for me, so the four hours would be four hours again.

The seven texts stayed. They were right.

When your agent runs a command that writes, what does it say back before, not after? And who's listening?