Agents in the Open Part 5: Stopping Without Cutting an Agent Off

The first four parts were about what the Orrery Lab must never do. This one is about what it must always finish. An agent's turn isn't a quick request: it can call five tools over thirty seconds, and...

The first four parts were about what the Orrery Lab must never do. This one is about what it must always finish. An agent's turn isn't a quick request: it can call five tools over thirty seconds, and one of them may be halfway through when you deploy. Orrery 0.6.6 fixed three reliability gaps that only show up once something is running for other people. Each one was a default that looked fine.


A Turn Outlives Its Client, So Shutdown Must Wait For It

Since Part 16, a streamed turn keeps running when the browser tab closes. That's deliberate: a closed tab must not cut a tool call in half. Orrery keeps a strong reference to each running turn, because asyncio holds only a weak one, and a task nobody awaits can otherwise be garbage-collected mid-run.

The review found the other half was missing. On SIGTERM, uvicorn waits for connected requests. A turn whose client had left had no connection, so nothing waited for it, and a rolling deploy ended it mid-tool: exactly what keeping it alive was meant to prevent.

Shutdown now has two phases:

finally:                                   # FastAPI lifespan, on SIGTERM
    await drain_turns(cfg.shutdown_grace_seconds)   # default 30 s

The Timeouts Must Fit Inside Each Other

A grace period only helps if the layer above allows it. There are three nested clocks, and each must fit inside the one around it:

Shutdown timeline: SIGTERM, uvicorn waits 15 s for connected requests, Orrery drains abandoned turns for up to 30 s, all inside Docker's 50 s stop grace period before SIGKILL

Docker's default is 10 seconds. With it, all of the above would end in SIGKILL long before the drain finished, and nothing would log why. When you add a grace period anywhere, check the one around it.


The Default Command Is a Production Decision

While adding the drain, I followed where it would actually run, and found something worse. The published image's default command, and the Helm chart's, was adk web. That is Google ADK's developer UI. It ignores AUTH_ENABLED and the JWT_* settings, and lets anyone read any user's sessions. A deployment that relied on the defaults had none of Orrery's front door: no sign-in, no rate limits, no verified approvals, no CSP, no drain.

The lab was never exposed, because its Compose file named its own command. That's luck, not design. So in 0.6.6:

A default is what runs when nobody made a decision. It should be the configuration you would choose for a stranger's production.


"Incomplete" Is Not "Healthy"

The third gap was in how a scheduled health sweep reports its result. A sweep runs one checker per system and then a summarizer that records a verdict. Before these fixes, a checker that reported nothing, or a run with no system to check at all, could end as complete and healthy. Silence was read as good news.

Now the run records, for each system, whether its checker reported at all, and the exit code says what happened:

A cron job or a CI step can tell "all good" from "didn't look". And with no specialist able to remediate (the lab has none), the workflow has no remediation loop at all. The report says a human must act, instead of trying anyway.


The Pattern

The lab now runs Orrery 0.6.6, verified and pinned by digest. The lab isn't open to readers yet (only Tirraflow admins can sign in), and I will update Part 1 when that changes. Until then, the same stack runs locally: make install && make demo from the Orrery repository, with your own model API key, seeds the same Kafka incident and opens the console on localhost:8000.

Read on Tirraflow