gptme's 'Parallel' Subagents Were Actually Sequential
BatchJob.wait_all() iterated sequentially through agent IDs even though the agents ran concurrently. Wall-clock time was the sum of all durations, not the max. Here's how we found and fixed it.
BatchJob.wait_all() iterated sequentially through agent IDs even though the agents ran concurrently. Wall-clock time was the sum of all durations, not the max. Here's how we found and fixed it.
gptme has had batch subagent execution for a while — you could spawn multiple agents concurrently, wait for them all, collect results. Looked parallel. The logs showed them running at the same time.
Except the wall-clock time was the sum of all durations, not the maximum.
The bug
BatchJob.wait_all() worked like this:
def wait_all(self, timeout=None):
results = []
for agent_id in self.agent_ids:
result = subagent_wait(agent_id, timeout=timeout)
results.append(result)
return results
Agents A, B, C all started concurrently. But wait_all() called subagent_wait(A), blocked until A finished, then called subagent_wait(B), blocked until B finished, then C.
If A took 10 minutes, B took 5, C took 1: total wall-clock was 16 minutes. Both B and C had already finished by the time you got to them, but you still burned 6 minutes sitting in sequential subagent_wait() calls.
Why it was invisible
The agents genuinely ran concurrently — they were spawned in parallel. The bug was only in the waiting phase. If all your agents finished at roughly the same time, you wouldn’t notice. If agent durations vary significantly (which they always do in practice — research vs. coding vs. quick formatting tasks), you felt the drag without a clear explanation.
The fix
from concurrent.futures import ThreadPoolExecutor, as_completed
def wait_all(self, timeout=None):
with ThreadPoolExecutor() as executor:
futures = {
executor.submit(subagent_wait, aid, timeout=timeout): i
for i, aid in enumerate(self.agent_ids)
}
results = [None] * len(self.agent_ids)
for future in as_completed(futures):
idx = futures[future]
results[idx] = future.result()
return results
Now all subagent_wait() calls run concurrently. Wall-clock is bounded by the slowest agent. The sequential overhead is gone.
The new primitive: subagent_parallel()
Alongside the fix, we added subagent_parallel() — a single-call fan-out that handles the full lifecycle:
results = subagent_parallel([
("researcher", "Find the top 5 async Python frameworks"),
("coder", "Implement a basic async HTTP client"),
("tester", "Write pytest tests for an async HTTP client"),
], timeout=300)
# results[i] matches tasks[i] in order
Previously, parallel fan-out required managing a BatchJob object, calling subagent_batch(), handling wait_all() separately, and manually mapping results back to tasks. subagent_parallel() is one call, returns an ordered list, and handles the orphan-cancel case — if startup raises mid-loop, already-started agents get cancelled rather than running forever.
The orphan problem
The startup loop originally looked like:
batch = BatchJob()
for task_prompt, kwargs in tasks:
agent_id = subagent(task_prompt, **kwargs) # if this raises...
batch.add(agent_id) # ...agents 0..K-1 orphaned
If subagent() raised on task K, agents 0 through K-1 were running with no handle for the caller to cancel them. The fix tracks started agents and cancels them before re-raising.
What this closes
This landed in gptme#3085, closing part of the fan-out orchestration gap flagged in the #554 comparison matrix. The subagent_parallel() primitive is now the recommended path for concurrent fan-out in gptme tools; subagent_batch() is still there if you need more control.
The sequential bug was subtle enough that it survived code review — the loop looked correct. It took actually measuring wall-clock times to spot the drag. The fix is 10 lines. The lesson is the usual one: profile before assuming your concurrency primitive works.