background-task-concurrency.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. export interface BackgroundTaskConcurrencyConfig {
  2. defaultConcurrency: number;
  3. providerConcurrency: Readonly<Record<string, number>>;
  4. modelConcurrency: Readonly<Record<string, number>>;
  5. }
  6. export interface BackgroundTaskConcurrencyRequest {
  7. model?: string;
  8. }
  9. export interface BackgroundTaskConcurrencyTicket {
  10. readonly ready: Promise<void>;
  11. bind(taskID: string): void;
  12. release(): void;
  13. releaseIfUnbound(): void;
  14. }
  15. type ConcurrencyTier = 'model' | 'provider' | 'default';
  16. interface QueueEntry {
  17. id: number;
  18. model?: string;
  19. provider?: string;
  20. /** Resolved cap tier. Only ONE tier applies per task (model > provider > default). */
  21. tier: ConcurrencyTier;
  22. /** Key counted against for model/provider tiers (model ID or provider ID). */
  23. key?: string;
  24. /** Resolved cap for the tier; Infinity when the tier is unlimited (0). */
  25. limit: number;
  26. started: boolean;
  27. released: boolean;
  28. taskID?: string;
  29. resolve: () => void;
  30. reject: (error: Error) => void;
  31. }
  32. export class BackgroundTaskConcurrencyQueueCancelledError extends Error {
  33. constructor() {
  34. super('Background task concurrency queue was cancelled');
  35. this.name = 'BackgroundTaskConcurrencyQueueCancelledError';
  36. }
  37. }
  38. /**
  39. * Process-local admission scheduler for native background task launches.
  40. *
  41. * Limits follow the reference implementation's override semantics: a model
  42. * cap for the task's model wins over a provider cap for its provider, which
  43. * wins over the default cap — only the most specific configured cap applies.
  44. * A configured value of `0` means unlimited for that key. Queued requests are
  45. * admitted in order, but entries whose resolved tier is saturated are skipped
  46. * in favor of admittable later entries (FIFO with skip). A ticket owns
  47. * capacity from the moment its `ready` promise resolves until the bound task
  48. * reaches a terminal state. The job board still owns task lifecycle; this
  49. * scheduler only controls admission.
  50. *
  51. * State is scoped to the scheduler instance. Plugin generations share this
  52. * scheduler through the per-directory lease in `src/admission-runtime.ts`.
  53. * `restoreTask` covers the one case the shared instance cannot: a genuine
  54. * process restart that resumes a still-running task from persisted history.
  55. */
  56. export class BackgroundTaskConcurrency {
  57. private readonly waiting: QueueEntry[] = [];
  58. private readonly active = new Set<QueueEntry>();
  59. private readonly activeByKey = new Map<string, number>();
  60. private readonly activeByTaskID = new Map<string, QueueEntry>();
  61. private activeDefault = 0;
  62. private nextID = 0;
  63. private disposed = false;
  64. constructor(private config: BackgroundTaskConcurrencyConfig) {}
  65. /**
  66. * Apply a new configuration to this instance (used when the plugin factory
  67. * re-runs with changed config). Both running slots and queued tickets are
  68. * re-resolved against the new config: active entries move their accounting
  69. * to the tier their model now resolves to (so a newly lowered cap starts
  70. * counting tasks that were admitted under an unlimited/looser config), and
  71. * the queue re-pumps. Existing tasks are never terminated by a config
  72. * change — a running task that now exceeds a tightened cap keeps running
  73. * and blocks new admissions until it finishes.
  74. */
  75. updateConfig(config: BackgroundTaskConcurrencyConfig): void {
  76. this.config = config;
  77. for (const entry of this.active) {
  78. const tier = resolveTier(this.config, entry.model);
  79. if (
  80. tier.tier === entry.tier &&
  81. tier.key === entry.key &&
  82. tier.limit === entry.limit
  83. ) {
  84. continue;
  85. }
  86. this.untrack(entry);
  87. entry.tier = tier.tier;
  88. entry.key = tier.key;
  89. entry.limit = tier.limit;
  90. this.track(entry);
  91. }
  92. for (const entry of this.waiting) {
  93. const tier = resolveTier(this.config, entry.model);
  94. entry.tier = tier.tier;
  95. entry.key = tier.key;
  96. entry.limit = tier.limit;
  97. }
  98. this.pump();
  99. }
  100. isDisposed(): boolean {
  101. return this.disposed;
  102. }
  103. acquire(
  104. request: BackgroundTaskConcurrencyRequest,
  105. ): BackgroundTaskConcurrencyTicket {
  106. let resolveReady!: () => void;
  107. let rejectReady!: (error: Error) => void;
  108. const ready = new Promise<void>((resolve, reject) => {
  109. resolveReady = resolve;
  110. rejectReady = reject;
  111. });
  112. const model = normalizeModel(request.model);
  113. const tier = resolveTier(this.config, model);
  114. const entry: QueueEntry = {
  115. id: ++this.nextID,
  116. model,
  117. provider: providerFromModel(model),
  118. tier: tier.tier,
  119. key: tier.key,
  120. limit: tier.limit,
  121. started: false,
  122. released: false,
  123. resolve: resolveReady,
  124. reject: rejectReady,
  125. };
  126. if (this.disposed) {
  127. entry.released = true;
  128. rejectReady(new BackgroundTaskConcurrencyQueueCancelledError());
  129. } else {
  130. this.waiting.push(entry);
  131. this.pump();
  132. }
  133. return {
  134. ready,
  135. bind: (taskID) => this.bind(entry, taskID),
  136. release: () => this.release(entry),
  137. releaseIfUnbound: () => {
  138. if (entry.taskID === undefined) this.release(entry);
  139. },
  140. };
  141. }
  142. releaseTask(taskID: string): void {
  143. const entry = this.activeByTaskID.get(taskID);
  144. if (entry) this.release(entry);
  145. }
  146. /**
  147. * Claim a slot for a task that is already running. Used to restore the
  148. * admission state after a plugin re-init (or a process restart that resumes
  149. * a live run), where the fresh scheduler cannot know about tasks that were
  150. * admitted by a previous generation. Idempotent: a task that already holds
  151. * a slot is left untouched. Restores bypass the resolved caps because the
  152. * task is already in flight — we are reconstructing reality, not admitting
  153. * new work.
  154. */
  155. restoreTask(taskID: string, model?: string): void {
  156. if (this.disposed || !taskID || this.activeByTaskID.has(taskID)) return;
  157. const normalized = normalizeModel(model);
  158. const tier = resolveTier(this.config, normalized);
  159. const entry: QueueEntry = {
  160. id: ++this.nextID,
  161. model: normalized,
  162. provider: providerFromModel(normalized),
  163. tier: tier.tier,
  164. key: tier.key,
  165. limit: tier.limit,
  166. started: true,
  167. released: false,
  168. taskID,
  169. resolve: () => {},
  170. reject: () => {},
  171. };
  172. this.active.add(entry);
  173. this.activeByTaskID.set(taskID, entry);
  174. this.track(entry);
  175. }
  176. /**
  177. * Atomically move a running task's accounting from its admission
  178. * model/provider to a new model. Keeps provider/model caps correct when a
  179. * child session switches models mid-flight (foreground fallback, runtime
  180. * model switch). No-op when the task is unknown or already on that model.
  181. */
  182. migrateTask(taskID: string, model: string | undefined): void {
  183. const entry = this.activeByTaskID.get(taskID);
  184. if (!entry || entry.released) return;
  185. const nextModel = normalizeModel(model);
  186. if (entry.model === nextModel) return;
  187. const tier = resolveTier(this.config, nextModel);
  188. this.untrack(entry);
  189. entry.model = nextModel;
  190. entry.provider = providerFromModel(nextModel);
  191. entry.tier = tier.tier;
  192. entry.key = tier.key;
  193. entry.limit = tier.limit;
  194. this.track(entry);
  195. // Moving a task off a saturated key can free capacity for waiters.
  196. this.pump();
  197. }
  198. dispose(): void {
  199. if (this.disposed) return;
  200. this.disposed = true;
  201. for (const entry of [...this.waiting, ...this.active]) {
  202. this.release(entry);
  203. }
  204. }
  205. /** Test/diagnostic seam. */
  206. snapshot(): { active: number; queued: number } {
  207. return { active: this.active.size, queued: this.waiting.length };
  208. }
  209. private bind(entry: QueueEntry, taskID: string): void {
  210. if (!entry.started || entry.released || !taskID) return;
  211. if (entry.taskID === taskID) return;
  212. const existing = this.activeByTaskID.get(taskID);
  213. if (existing && existing !== entry) {
  214. // A restored slot already claims this taskID (e.g. the task was
  215. // rehydrated after a re-init before this ticket got bound). Drop the
  216. // restored slot so the admitted ticket becomes the single owner.
  217. this.release(existing);
  218. }
  219. if (entry.taskID !== undefined) {
  220. this.activeByTaskID.delete(entry.taskID);
  221. }
  222. entry.taskID = taskID;
  223. this.activeByTaskID.set(taskID, entry);
  224. }
  225. private pump(): void {
  226. if (this.disposed) return;
  227. while (true) {
  228. const index = this.waiting.findIndex((entry) => this.canStart(entry));
  229. if (index < 0) return;
  230. const [entry] = this.waiting.splice(index, 1);
  231. if (!entry || entry.released) continue;
  232. entry.started = true;
  233. this.active.add(entry);
  234. this.track(entry);
  235. entry.resolve();
  236. }
  237. }
  238. private canStart(entry: QueueEntry): boolean {
  239. if (entry.limit === Infinity) return true;
  240. if (entry.tier === 'default') return this.activeDefault < entry.limit;
  241. return (this.activeByKey.get(entry.key ?? '') ?? 0) < entry.limit;
  242. }
  243. private track(entry: QueueEntry): void {
  244. if (entry.limit === Infinity) return;
  245. if (entry.tier === 'default') {
  246. this.activeDefault += 1;
  247. } else {
  248. increment(this.activeByKey, entry.key);
  249. }
  250. }
  251. private untrack(entry: QueueEntry): void {
  252. if (entry.limit === Infinity) return;
  253. if (entry.tier === 'default') {
  254. this.activeDefault -= 1;
  255. } else {
  256. decrement(this.activeByKey, entry.key);
  257. }
  258. }
  259. private release(entry: QueueEntry): void {
  260. if (entry.released) return;
  261. entry.released = true;
  262. const waitingIndex = this.waiting.indexOf(entry);
  263. if (waitingIndex >= 0) {
  264. this.waiting.splice(waitingIndex, 1);
  265. entry.reject(new BackgroundTaskConcurrencyQueueCancelledError());
  266. this.pump();
  267. return;
  268. }
  269. if (entry.started) {
  270. this.active.delete(entry);
  271. this.untrack(entry);
  272. }
  273. if (entry.taskID !== undefined) {
  274. this.activeByTaskID.delete(entry.taskID);
  275. }
  276. this.pump();
  277. }
  278. }
  279. export {
  280. getBackgroundTaskConcurrency,
  281. resetBackgroundTaskConcurrencyForTests,
  282. } from '../admission-runtime';
  283. /** Resolve the single applicable cap tier for a model (model > provider > default). */
  284. function resolveTier(
  285. config: BackgroundTaskConcurrencyConfig,
  286. model: string | undefined,
  287. ): { tier: ConcurrencyTier; key?: string; limit: number } {
  288. if (model !== undefined) {
  289. const modelLimit = config.modelConcurrency[model];
  290. if (modelLimit !== undefined) {
  291. return { tier: 'model', key: model, limit: enabledLimit(modelLimit) };
  292. }
  293. const provider = providerFromModel(model);
  294. if (provider !== undefined) {
  295. const providerLimit = config.providerConcurrency[provider];
  296. if (providerLimit !== undefined) {
  297. return {
  298. tier: 'provider',
  299. key: provider,
  300. limit: enabledLimit(providerLimit),
  301. };
  302. }
  303. }
  304. }
  305. return { tier: 'default', limit: enabledLimit(config.defaultConcurrency) };
  306. }
  307. function normalizeModel(model: string | undefined): string | undefined {
  308. const value = model?.trim();
  309. return value || undefined;
  310. }
  311. function providerFromModel(model: string | undefined): string | undefined {
  312. if (!model) return undefined;
  313. const slash = model.indexOf('/');
  314. return slash > 0 ? model.slice(0, slash) : undefined;
  315. }
  316. /** 0 (and absent/negative) means unlimited; a positive value caps the tier. */
  317. function enabledLimit(limit: number | undefined): number {
  318. return typeof limit === 'number' && limit > 0 ? limit : Infinity;
  319. }
  320. function increment(map: Map<string, number>, key: string | undefined): void {
  321. if (!key) return;
  322. map.set(key, (map.get(key) ?? 0) + 1);
  323. }
  324. function decrement(map: Map<string, number>, key: string | undefined): void {
  325. if (!key) return;
  326. const next = (map.get(key) ?? 0) - 1;
  327. if (next > 0) map.set(key, next);
  328. else map.delete(key);
  329. }