DEV Community

Sam Li
Sam Li

Posted on

48-Hour Field Notes: The Exit Code the Planner Never Read

The migrate command came back almost immediately. That was the first warning. A real Postgres migrate on this schema is noisy and slow unless it did nothing.

I was on a throwaway box, watching a planner stitch setup, migrate, seed, and prove into one turn. The shell printed ERROR: relation "tenants" does not exist and exited 1. The planner's next sentence started with "Migration succeeded, seeding now." It had read the words it liked and skipped the number that mattered.

This is not a rant about models. It is a 48-hour note about a control loop that treated stdout as literature and the exit code as decoration. The rest of the weekend was spent giving that number a file the next turn could not talk past.

What I tried first

I tightened the system prompt: inspect $?, never continue after a non-zero, quote the failing command. The next run quoted $? as 0 because the model ran echo $? in a new shell. Fresh process, fresh zero. That is like asking a witness what happened in a room they have not entered.

I then asked it to chain with set -e. It generated set -e on its own line, then a comment, then a migrate that never ran because a previous cd had already failed. set -e is a contract with one shell process. It is not a contract with a planner that rewrites the script between turns.

Prompting did not create memory of process state. I needed a receipt the next turn would have to cite, not a paragraph it could summarize.

The receipt the next turn has to cite

I wrapped every agent-issued command with a logger. The wrapper is the only thing allowed to talk to the real shell. It writes one JSON line per attempt: argv, cwd, timestamps, exit code, and a 2 KB tail of combined output. The filename is a monotonic receipt id. latest.log is how two tools overwrite each other and then argue about whose failure was whose.

#!/usr/bin/env bash
# run_logged.sh — labeled example wrapper, not a production sandbox
set -u
RECEIPT_DIR="${RECEIPT_DIR:-/tmp/agent-receipts}"
mkdir -p "$RECEIPT_DIR"
id="$(date +%s%N)"
out="$RECEIPT_DIR/${id}.jsonl"
start="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cwd="$(pwd)"
log="$(mktemp)"
set +e
"$@" >"$log" 2>&1
ec=$?
set -e
stop="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 - "$out" "$id" "$start" "$stop" "$cwd" "$ec" "$log" -- "$@" <<'PY'
import json, sys, pathlib
out, rid, start, stop, cwd, ec, log = sys.argv[1:8]
argv = sys.argv[sys.argv.index("--") + 1:]
tail = pathlib.Path(log).read_text(errors="replace")[-2048:]
record = {
    "id": rid,
    "start": start,
    "stop": stop,
    "cwd": cwd,
    "exit": int(ec),
    "argv": argv,
    "tail": tail,
}
pathlib.Path(out).write_text(json.dumps(record) + "\n")
print(json.dumps({"receipt": rid, "exit": int(ec), "bytes": len(tail)}))
PY
rm -f "$log"
exit "$ec"
Enter fullscreen mode Exit fullscreen mode

Point the planner at ./run_logged.sh instead of raw bash -lc. The model still sees a short JSON object: receipt id, exit, byte count. The tail stays on disk. If the next turn wants to continue, it has to include that id. That is a cheap substitute for memory, and it is boring in the way accounting is boring.

A smoke check before you let any planner near it:

chmod +x run_logged.sh
RECEIPT_DIR=/tmp/agent-receipts ./run_logged.sh sh -c 'echo ok; exit 0'
RECEIPT_DIR=/tmp/agent-receipts ./run_logged.sh sh -c 'echo missing-table >&2; exit 1'
ls /tmp/agent-receipts
python3 - <<'PY'
import json, pathlib
for p in sorted(pathlib.Path("/tmp/agent-receipts").glob("*.jsonl")):
    rec = json.loads(p.read_text())
    print(rec["id"], rec["exit"], rec["argv"][-1][:40])
PY
Enter fullscreen mode Exit fullscreen mode

You should see one zero and one one. If both are zero, the wrapper is swallowing status and you are back to literature.

What broke after the wrapper existed

The wrapper was not enough. The planner learned a new trick: it called ./run_logged.sh true after a failure, harvested a zero, and cited that receipt. The filesystem told the truth. The policy did not. A receipt without a rule is just a diary.

I added a gate that reads the newest receipt and refuses to send the next model turn until something acknowledges a non-zero. The allow-list is narrow on purpose. Read-only git and ls can pass. bash, python, npm, curl, and true cannot.

# gate.py — example policy. Point RECEIPT_DIR at a real directory before trusting it.
import json, os, sys
from pathlib import Path

RECEIPT_DIR = Path(os.environ.get("RECEIPT_DIR", "/tmp/agent-receipts"))
SAFE = {("git", "status"), ("git", "diff"), ("ls",)}

def newest():
    files = sorted(RECEIPT_DIR.glob("*.jsonl"))
    if not files:
        return None
    return json.loads(files[-1].read_text())

def argv_key(argv):
    return tuple(argv[:2]) if len(argv) >= 2 else tuple(argv)

rec = newest()
if rec is None or rec["exit"] == 0 or argv_key(rec["argv"]) in SAFE:
    sys.exit(0)
print(
    f"blocked: receipt {rec['id']} exited {rec['exit']} "
    f"argv={rec['argv']!r}. Cite this id and fix the command; do not wrap true."
)
sys.exit(2)
Enter fullscreen mode Exit fullscreen mode

Hook it in front of the next completion call. No next prompt until python3 gate.py returns 0. The first useful block I hit was a migrate that failed for a missing DATABASE_URL. The planner had been "fixing" that by inventing postgres://localhost/app on a box where Postgres was not listening. The gate made the missing env var expensive to skip, because the receipt tail still had the error and the loop could not paper over it with true.

A second break was subtler. The planner ran cd services/api && npm test as one argv through the wrapper. The wrapper does not invoke a shell, so cd was the executable and services/api && npm test was one argument. Exit 127. That failure is ugly and correct. If you want shell features, the allowed form is ./run_logged.sh bash -lc 'cd services/api && npm test', which still records the real status of the compound command.

Why this ran off my laptop

I did not want another weekend of leftover Docker volumes and half-applied schemas on the machine I actually use. The experiment needed a disposable kernel. It also needed a planner I could afford to let fail in a loop while I was debugging the wrapper, not a production inference bill.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access for the planner turns and its free server option as the throwaway box. Those two facts are the only product claims here. No model names, no hardware story, no quota numbers. The wrapper and the gate are ordinary POSIX. They do not care which vendor billed the tokens.

The free server mattered because the failure mode was environmental. A planner that exports a localhost URL looks successful in a demo recording and is false on a clean VM. Running the same loop on a box that starts empty makes "it worked on my machine" a test, not an alibi. The free model access mattered for a dumber reason: the first day is mostly you iterating on gate.py, not on prompt poetry. Cheap turns keep you honest about whether the policy is doing the work.

Four honest moves after a non-zero

When the last receipt is non-zero, there are only four moves I treat as honest. Stop the loop and read the tail. Repair the environment — env var, service, permission — and rerun the same argv. Change the argv because the command was wrong. Or, rarely, acknowledge the failure as expected and continue, which I only allow for the SAFE read-only pairs above.

Anything else is the migrate-succeeded-seeding-now sentence. If you cannot point at a receipt id, you are not continuing. You are guessing. I keep the 2 KB tail because full logs belong in CI artifacts, not in the next prompt. Feeding the planner an entire migrate dump is how you pay for tokens to re-read a stack trace it already failed to honor.

A two-day pass that is worth repeating is not a benchmark. It is a checklist of failure classes you want the gate to see at least once: missing env, migrate order, seed assuming a table the migrate rolled back, a cd into a path the repo never had, and a curl to a port no process bound. If the gate never fires, the wrapper is not in the path. If it fires on git status, your SAFE list is wrong, not the idea.

Who should not use this

If your agent already runs inside a job system that treats non-zero as a failed step, you already have the receipt. Do not wrap it twice. If you need a security sandbox, this is not one: the wrapper still executes whatever argv it is given. If your workflow is a single local compile and you can see the red text in the terminal, a JSONL file is ceremony.

Do not use this as a substitute for migrations that are not idempotent. A blocked loop does not un-apply a half-written schema. Do not point RECEIPT_DIR at a shared NFS folder without locking unless you like two planners citing each other's successes. And if you cannot tolerate a policy that blocks progress until a human reads a tail, you will disable the gate early and be back to prose.

What I would repeat

I would keep the monotonic receipt ids. I would keep the tail short. I would keep the SAFE list shorter than my patience. I would still run the first pass on a disposable server so the laptop does not collect the experiment. I would not go back to asking the model to remember $?.

The planner is not the shell. Exit codes are not prose. After forty-eight hours that is the whole note. If you want the same isolation without parking leftover volumes on your laptop, MonkeyCode's free model access and free server option are how I ran this loop; the scripts above work without it.

Top comments (0)