A problem you never see with one agent
With a single agent you just watch the screen. The moment you run several at once, a program has to decide which ones are working — and almost everyone reaches for the same implementation first.
bytes came out of the PTY → working
nothing for N seconds → idle
Intuitive, and wrong. What makes it hard to spot is that it is wrong in both directions at once, so fixing one side makes the other worse.
Direction 1 — finished agents look busy
When an agent CLI finishes and sits at its prompt, it keeps repainting the cursor and spinner. Every repaint is PTY bytes.
Output-based detection promotes the agent back to "working" on each one. An idle agent stays busy forever.
The usual patch is a settle window — "only call it idle N seconds after the last output." That does not remove the misclassification, it delays it by N seconds. Spinners repaint more often than that.
Direction 2 — working agents look idle
This side is more dangerous.
An agent that is reasoning produces no PTY output at all. While the model generates a long response, while it waits on a tool result, the terminal is completely silent.
So any inactivity timeout will eventually mark a genuinely working agent as idle — and then the real damage starts. The orchestrator hands it more work, or reclaims it mid-task.
The rule — define turns by boundaries, not noise
A turn is defined by its start and end. It starts on submitted input and ends on an explicit completion report. Output is a hint, never the state.
Two details are easy to miss when implementing that.
Human input opens a turn too. If only programmatic submissions open turns, sessions where a person typed directly into the agent's terminal get stuck in idle forever. Open a turn on carriage return from any source. Individual keystrokes do not count — they may be mid-composition in an IME.
Emit the turn-start event when the input is accepted, not when it flushes. With queued or async write paths, a flush-time event arrives after the code that depends on that state has already read it.
A related bug — the self-destroying evidence pattern
While fixing liveness we hit a more general bug. It is worth naming because the shape recurs far beyond agents.
Our reclamation logic required an agent to be bound to a task ID to be eligible for cleanup. Our completion handler cleared that task ID as its first act.
The event that made an agent eligible for cleanup destroyed the evidence cleanup needed.
The outcome was inverted. Agents that finished cleanly became permanently unreclaimable, while only messily-dead ones got collected. The load average reached 44 on a 12-core machine with 11 zombie agents, while the cleanup tool reported "no reclaimable agents found."
When a cleanup gate depends on state X, check whether the normal, happy-path completion also clears X. If it does, preserve the evidence under a separate key at unbind time.
And the fix you should not reach for
Faced with this, one rule suggests itself — "no binding and idle for a long time → reclaim."
Do not. Agents that are still booting, and agents whose binding was never recorded, are indistinguishable from finished ones under that rule.
Absence of evidence is not evidence of completion. Report them as suspect, never reap them.
Summary
| Common implementation | What actually happens |
|---|---|
| PTY output = working | Finished agents stay busy on spinner repaints |
| Inactivity timeout = idle | Reasoning agents get reclaimed mid-task |
| Settle window | Delays the misclassification instead of removing it |
| Input submitted → completion reported | Boundaries define the state |
Rather than implementing this detection yourself, it is faster to read state off a board. Marblo tracks ticket state explicitly through TODO → IN PROGRESS → REVIEW → DONE and shows which ticket each agent is bound to.
Everything here was measured while running a heterogeneous fleet of agent CLIs in production. The full write-up is public in the Fleet Operations knowledge pack — readable without an account.
More from the same fleet
- Resume Flags Differ Per Harness — measured session-resume contracts
- Your Cost Table Is Empty and Both the Parser and the Router Are Correct — where cost attribution breaks silently