What broke
AgentOS is a gateway, GPU scheduler, memory service and code sandbox for AI agents that I build and run on one machine. These are things that went wrong while building it, how I found them, and what I changed. Each one lists the commits that fixed it.
I checked my own README against the code
By October the README listed a lot of features. I went through it line by line, and for each claim I followed a real request through the code to see whether it actually happened. Eight didn't.
The worst ones: budgets were counted but never stopped a call. A permission check on document buckets said 'allowed' whenever it couldn't reach its database. The audit hash skipped the request body. The rest were smaller. Streaming was buffered, rank fusion was written but never called, context injection was wrecking vLLM's prefix cache, there was no CI, and the 'HNSW' vector search was a full scan.
So I froze the roadmap (a durable-execution runtime, a trace lake, an MCP gateway and about ten other things) and fixed those eight first. Each fix started with a test that failed and ended with a number from a real run.
- 01
Budgets that didn't block anything
The README said agents had budgets. They did, in the sense that spend was recorded. When I wrote a test that pushed an agent over its daily limit and counted the calls reaching the model, the call went straight through: 200 where I expected 429.
The policy function returned None every time, and nothing on the request path called it anyway. Its unit tests passed because they only tested the function by itself.
The fix reserves an estimated cost before forwarding, against the agent, run and global limits at once, then corrects it to the real token count afterwards. If the budget store is down, the call is refused. These days my tests count what actually reaches the upstream.
200 concurrent requests against a limit of 5: exactly 5 got through, in all 100 trials. It costs about 14 µs per request.
80b01cf8d84099 - 02
Losing audit events to kill -9
I wanted to know whether 'every request is audited' survives a crash. The harness sends traffic, kills the proxy with kill -9 mid-load, restarts it, and checks every request the client got a 200 for against the database.
At 600 req/s, 3 of 30 runs lost one event each. The response event was written from a background task, so if the kill landed in the millisecond after the client got its answer, the event was gone. The same runs turned up a second bug: 11 of them failed hash-chain verification, because two requests starting at once on a new session each picked a different first link.
Now the event goes into a write-ahead journal before the client is answered, and the chain's first link comes from the session id. Before this, a full queue just dropped events and logged a warning. I had been calling that 'fail open'.
50 runs, 0 events lost, 0 duplicated. In one of them, 52,804 acknowledged requests produced all 105,608 expected events; 126 of those only survived because of the journal.
1e218458d25aa2beec9f7c81eb21 - 03
Saying 'allowed' when it couldn't check
Before Govrix pulls documents into a prompt, it checks that the agent owns the bucket. Reading that code during the audit, I found that a timeout or a 500 from the ownership lookup counted as yes.
I'd made 'fail open' the default for the audit path, where it's the right call, and it had crept into a permission check, where it means one agent can read another agent's documents during an outage.
Now an unanswered check is a no. The one exception is a 404 just after a bucket is created, because of a create-then-ingest race, and that exception is written down next to the code.
Ownership is checked on every retrieval Govrix injects, and errors deny.
472e6ac5c93951 - 04
Context injection broke the prefix cache
With memory injection on, vLLM's prefix-cache hit rate over a 20-turn chat fell to 9%. Going straight to vLLM without the proxy, it was 95%.
I was adding retrieved context as a new system message at the very top of the prompt. That changed the first bytes on every turn, so nothing after them could be reused. Re-serialising the JSON body also reordered its keys: the same request, different bytes.
Now the context goes at the start of the last user message, spliced in as raw bytes, and the rest of the request passes through untouched.
Hit rate went from 0.094 to 0.826, and p99 time to first token from 186.9 ms to 129.5 ms.
c4eef3a - 05
Sandboxes nobody cleaned up
One day Crucible couldn't create a sandbox. Docker said 'all predefined address pools have been fully subnetted'. Every sandbox gets its own container and network, nothing ever deleted them, and 29 had piled up.
Pausing a sandbox also reset its idle timer, so paused ones would never have expired even with a cleanup job.
I added a reaper. It clears orphans when the service starts, expires idle sandboxes every 30 seconds and caps how many can exist. Review caught a thread race in my first version.
The first restart removed 14 real leftovers. I watched a fresh sandbox sit through the 15-minute idle limit and get reaped.
0d81d1a6ed4223 - 06
The limit that was really my load generator
The first load test crashed the proxy at about 1,300 users with 'Too many open files'. That one was easy: the container's file limit was 2,048.
After raising it, errors started near 2,500 users, and I wrote that down as the proxy's limit. It wasn't. A single Locust process was saturating, and a bug in my failure handler had hidden 55,185 exceptions.
With 4 Locust workers the errors went away. I retracted the 2,500 number in its own commit.
383,254 requests from 50 to 3,200 users with 0% errors. Throughput levelled off around 1,700 req/s with the GPU at 100%.
8a5dd7177ff3654f1e07a - 07
Four replicas that fit on paper
Four vLLM replicas at a quarter of the GPU each should fit on a 32 GB card. Three started. The fourth ran out of memory at 31,955 of 32,607 MiB.
Every replica pays a fixed CUDA overhead that the fraction ignores, and the desktop itself was holding 2.3 to 2.75 GiB. While digging, I also found a KV-cache hook that reported success but that vLLM never called, and an offload function that quietly did nothing.
I run two replicas at 0.4 now. The fake hook and the silent offload now fail loudly instead.
Two replicas at 0.4 leave about 0.43 GiB free. The planner has a reserved-memory setting, but it isn't wired into the live path yet.
7850862