Files
oh-my-pi/packages/coding-agent/src/task/provider-concurrency.ts
T
roboomp 2042d3b114 fix(task): scoped provider concurrency cap to each LLM turn
The per-provider semaphore (e.g. `providers.ollama-cloud.maxConcurrency`) was acquired before `SessionManager.open` and released only after `driveSessionToYield` returned, so it bracketed the whole subagent lifecycle. Any spawn tree wider than `maxConcurrency` deadlocked: parents held every slot while waiting for children that were queued on the same cap — symptoms matched zero LLM requests and tokens=0/requests=0 cancellations.

Moved the bracket into a `StreamFn` wrapper. The wrapper acquires the slot just before each provider HTTP request and releases it the moment the response stream produces 'done'/'error', so a parent's slot is free between turns and child subagents can acquire while their parent's tool calls run. Wraps both the main agent and the advisor (both consume `settingsAwareStreamFn`).

Fixes #3749
2026-06-28 22:50:59 +02:00

101 lines
3.9 KiB
TypeScript

/**
* Per-provider LLM concurrency cap, applied around each provider HTTP request.
*
* The semaphore brackets only the streaming request itself, not the whole
* agent lifetime: a parent subagent releases its slot the moment its LLM
* stream finishes producing, so children spawned during tool execution can
* acquire slots for their own turns. Holding the slot across the parent's
* full conversation deadlocks any spawn tree whose width exceeds
* `maxConcurrency` because the parents wait for children that wait for
* slots the parents are holding (issue
* [#3749](https://github.com/can1357/oh-my-pi/issues/3749)).
*/
import type { StreamFn } from "@oh-my-pi/pi-agent-core";
import type { Settings } from "../config/settings";
import type { SettingPath } from "../config/settings-schema";
import { Semaphore } from "./parallel";
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
};
interface ProviderSemaphoreEntry {
limit: number;
semaphore: Semaphore;
}
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
/**
* Resolve the configured concurrency ceiling for a provider, or `undefined`
* when the provider has no cap concept at all. A configured value `<= 0` means
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
* holds a slot and a later finite resize counts work started while unlimited.
*/
export function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
if (!settingPath) return undefined;
const raw = settings.get(settingPath);
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
}
/**
* Hand out the single shared limiter for `provider` (creating one lazily) and
* resize it in place when the configured limit changes. Replacing the
* semaphore would orphan in-flight slots on the old instance and let a
* runtime or mixed limit value exceed the cap (issue #3464 review feedback).
*/
export function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
const limit = getProviderConcurrencyLimit(settings, provider);
if (limit === undefined) return undefined;
const existing = providerSemaphores.get(provider);
if (existing) {
if (existing.limit !== limit) {
existing.limit = limit;
existing.semaphore.resize(limit);
}
return existing.semaphore;
}
const semaphore = new Semaphore(limit);
providerSemaphores.set(provider, { limit, semaphore });
return semaphore;
}
/**
* Wrap a {@link StreamFn} so every LLM HTTP request acquires the provider's
* concurrency slot before the request goes out and releases it when the
* stream finishes producing (success, error, or abort). Providers without a
* configured cap pass straight through.
*
* The acquire bracket is intentionally narrow (one slot per LLM call), so
* spawn trees deeper than `maxConcurrency` no longer deadlock on themselves —
* see the module-level comment for the failure mode this fixes.
*/
export function wrapStreamFnWithProviderConcurrency(settings: Settings, base: StreamFn): StreamFn {
return async (model, context, options) => {
const semaphore = getProviderSemaphore(settings, model.provider);
if (!semaphore) return base(model, context, options);
await semaphore.acquire(options?.signal);
let released = false;
const release = () => {
if (released) return;
released = true;
semaphore.release();
};
try {
const stream = await base(model, context, options);
// EventStream.result() settles when the producer pushes 'done'/'error'
// or calls fail() — i.e. once the provider has finished producing.
// Releasing here keeps the slot held for the network request and
// nothing else.
stream.result().then(release, release);
return stream;
} catch (err) {
release();
throw err;
}
};
}