Просмотр исходного кода

feat(background-jobs): add admission concurrency limits for native background tasks

HeZzz 2 недель назад
Родитель
Сommit
201c67ac78

+ 21 - 0
docs/background-orchestration.md

@@ -479,6 +479,27 @@ uncertain`; they never prove that a job stopped or completed and do not confirm
 a pending stop. Each observation is generation-aware, so a delayed response
 a pending stop. Each observation is generation-aware, so a delayed response
 cannot modify a relaunched task.
 cannot modify a relaunched task.
 
 
+### Background Task Concurrency
+
+`backgroundJobs.concurrency` (disabled by default, see
+[Configuration](configuration.md#background-job-management)) caps how many
+native background tasks may run at once. Admission happens in the
+`tool.execute.before` hook: a task waits for a slot before OpenCode creates
+its child session. Queued requests are admitted in order, but requests whose
+provider or model quota is saturated are skipped in favor of admittable later
+requests.
+
+Sessions that are themselves managed tasks — a background subagent running
+its own nested `task(..., background: true)` calls — are exempt from
+admission. They already hold a slot while running, so waiting for a second
+one would self-deadlock once the queue saturates.
+
+Admission itself has no timeout. A running task that never reaches a terminal
+state keeps its slot forever, and queued tasks as well as the orchestrator's
+`task` calls block behind it. When you enable `concurrency`, pair it with the
+opt-in wall-clock supervisor below so stalled tasks are eventually forced to
+a terminal state and release their slots.
+
 ### Opt-in Wall-clock Supervisor
 ### Opt-in Wall-clock Supervisor
 
 
 The plugin can apply a one-shot wall-clock deadline to native background task
 The plugin can apply a one-shot wall-clock deadline to native background task

+ 35 - 1
docs/configuration.md

@@ -161,6 +161,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.orchestratorWake.intervalMs` | integer | `300000` | Continuous parent-idle interval between wake evaluations (`60000`–`2147483647` ms). `0` is invalid. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.orchestratorWake.intervalMs` | integer | `300000` | Continuous parent-idle interval between wake evaluations (`60000`–`2147483647` ms). `0` is invalid. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
 | `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
 | `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
 | `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
+| `backgroundJobs.concurrency.defaultConcurrency` | integer | `0` | Maximum concurrently running native background tasks. `0` disables the default cap; accepted values are `0`–`1000` See [Background Job Management](#background-job-management). |
+| `backgroundJobs.concurrency.providerConcurrency` | object | `{}` | Per-provider caps keyed by provider ID. Each value must be `1`–`1000`; provider and model caps apply alongside the default cap See [Background Job Management](#background-job-management). |
+| `backgroundJobs.concurrency.modelConcurrency` | object | `{}` | Per-model caps keyed by `provider/model` ID. Each value must be `1`–`1000`; model caps apply alongside provider and default caps See [Background Job Management](#background-job-management). |
 | `backgroundJobs.waitForUserGuard` | boolean | `true` | When true, intercepts `wait_for_user` calls while background tasks are still running and the orchestrator wake scheduler is enabled, returning guidance to end the turn instead of blocking on manual input. See [Background Job Management](#background-job-management). |
 | `backgroundJobs.waitForUserGuard` | boolean | `true` | When true, intercepts `wait_for_user` calls while background tasks are still running and the orchestrator wake scheduler is enabled, returning guidance to end the turn instead of blocking on manual input. See [Background Job Management](#background-job-management). |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable Slim's foreground model-chain failover. It does not configure OpenCode provider/AI-SDK retries. |
 | `fallback.enabled` | boolean | `true` | Enable Slim's foreground model-chain failover. It does not configure OpenCode provider/AI-SDK retries. |
@@ -312,7 +315,16 @@ The wall-clock supervisor is separately opt-in and remains disabled unless
       "intervalMs": 300000
       "intervalMs": 300000
     },
     },
     "wallClockTimeoutMs": 900000,
     "wallClockTimeoutMs": 900000,
-    "abortGraceMs": 10000
+    "abortGraceMs": 10000,
+    "concurrency": {
+      "defaultConcurrency": 2,
+      "providerConcurrency": {
+        "openai": 2
+      },
+      "modelConcurrency": {
+        "openai/gpt-5.6-luna": 1
+      }
+    }
   }
   }
 }
 }
 ```
 ```
@@ -323,6 +335,28 @@ without periodic wake prompts. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
 defaults, and examples.
 
 
+`concurrency` limits only native background tasks with
+`task(..., background: true)`. Foreground tasks are unchanged. A task waits
+for admission before OpenCode creates its child session, so queued work does
+not consume a provider request. `defaultConcurrency: 0` means unlimited by
+default. Provider and model limits are additional caps, and the most specific
+model cap does not allow a task to exceed its provider or default cap. Queued
+tasks are admitted in order among tasks whose configured provider/model caps
+have capacity. Terminal completion, cancellation, failure, session deletion,
+and plugin disposal release the slot.
+
+Two behaviors to know about when concurrency is enabled:
+
+- Sessions that are themselves managed tasks (a background subagent
+  orchestrating its own nested `task(..., background: true)` calls) are
+  exempt from admission. They already hold a slot while running, so waiting
+  for a second one would self-deadlock once the queue saturates.
+- Admission has no timeout of its own. A running task that never reaches a
+  terminal state keeps its slot forever, and queued tasks as well as the
+  orchestrator's `task` calls block behind it. When you enable
+  `concurrency`, pair it with an opt-in `wallClockTimeoutMs` so stalled
+  tasks are eventually forced to a terminal state and release their slots.
+
 Configurations that still use the removed `backgroundJobs.continueOnIdle` key
 Configurations that still use the removed `backgroundJobs.continueOnIdle` key
 emit a deprecation warning and migrate its boolean value to
 emit a deprecation warning and migrate its boolean value to
 `orchestratorWake.enabled`. An `orchestratorWake.enabled` value in the same
 `orchestratorWake.enabled`. An `orchestratorWake.enabled` value in the same

+ 46 - 0
oh-my-opencode-slim.schema.json

@@ -1156,6 +1156,52 @@
           "minimum": 1000,
           "minimum": 1000,
           "maximum": 60000
           "maximum": 60000
         },
         },
+        "concurrency": {
+          "default": {
+            "defaultConcurrency": 0,
+            "providerConcurrency": {},
+            "modelConcurrency": {}
+          },
+          "type": "object",
+          "properties": {
+            "defaultConcurrency": {
+              "default": 0,
+              "description": "Maximum concurrently running native background tasks. 0 disables the default cap.",
+              "type": "integer",
+              "minimum": 0,
+              "maximum": 1000
+            },
+            "providerConcurrency": {
+              "default": {},
+              "description": "Per-provider concurrency caps keyed by provider ID.",
+              "type": "object",
+              "propertyNames": {
+                "type": "string",
+                "minLength": 1
+              },
+              "additionalProperties": {
+                "type": "integer",
+                "minimum": 1,
+                "maximum": 1000
+              }
+            },
+            "modelConcurrency": {
+              "default": {},
+              "description": "Per-model concurrency caps keyed by provider/model ID.",
+              "type": "object",
+              "propertyNames": {
+                "type": "string",
+                "minLength": 1
+              },
+              "additionalProperties": {
+                "type": "integer",
+                "minimum": 1,
+                "maximum": 1000
+              }
+            }
+          },
+          "additionalProperties": false
+        },
         "waitForUserGuard": {
         "waitForUserGuard": {
           "default": true,
           "default": true,
           "description": "When true, intercept wait_for_user calls made while background tasks are still running and the orchestrator wake scheduler is enabled, returning guidance to end the turn instead of blocking on manual input. Default enabled.",
           "description": "When true, intercept wait_for_user calls made while background tasks are still running and the orchestrator wake scheduler is enabled, returning guidance to end the turn instead of blocking on manual input. Default enabled.",

+ 1 - 0
src/config/codemap.md

@@ -163,6 +163,7 @@ This allows consumers to import directly from `src/config` rather than individua
 - `tmux`: Legacy tmux configuration (migrated to multiplexer)
 - `tmux`: Legacy tmux configuration (migrated to multiplexer)
 - `interview`: Interview feature configuration
 - `interview`: Interview feature configuration
 - `backgroundJobs`: Background job configuration
 - `backgroundJobs`: Background job configuration
+- `backgroundJobs.concurrency`: Optional default, provider, and model caps for native background task admission
 - `fallback`: Failover/retry configuration
 - `fallback`: Failover/retry configuration
 - `council`: Council configuration with presets and execution modes
 - `council`: Council configuration with presets and execution modes
 - `companion`: Companion animation configuration
 - `companion`: Companion animation configuration

+ 5 - 0
src/config/runtime.test.ts

@@ -126,6 +126,11 @@ describe('RuntimeConfig', () => {
     });
     });
     expect(runtime.backgroundJobs.maxSessionsPerAgent).toBe(2);
     expect(runtime.backgroundJobs.maxSessionsPerAgent).toBe(2);
     expect(runtime.backgroundJobs.strategy).toBe('latest');
     expect(runtime.backgroundJobs.strategy).toBe('latest');
+    expect(runtime.backgroundJobs.concurrency).toEqual({
+      defaultConcurrency: 0,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
     expect(runtime.fallback).toEqual({ enabled: true, maxRetries: 3 });
     expect(runtime.fallback).toEqual({ enabled: true, maxRetries: 3 });
     expect(runtime.webfetch.enabled).toBe(true);
     expect(runtime.webfetch.enabled).toBe(true);
     expect(runtime.acpAgents).toEqual({});
     expect(runtime.acpAgents).toEqual({});

+ 5 - 0
src/config/runtime.ts

@@ -82,6 +82,11 @@ const DEFAULT_BACKGROUND_JOBS: BackgroundJobsConfig = {
   orchestratorWake: { enabled: true, intervalMs: 300_000 },
   orchestratorWake: { enabled: true, intervalMs: 300_000 },
   wallClockTimeoutMs: 0,
   wallClockTimeoutMs: 0,
   abortGraceMs: 10_000,
   abortGraceMs: 10_000,
+  concurrency: {
+    defaultConcurrency: 0,
+    providerConcurrency: {},
+    modelConcurrency: {},
+  },
   waitForUserGuard: true,
   waitForUserGuard: true,
 };
 };
 
 

+ 42 - 0
src/config/schema.test.ts

@@ -243,6 +243,48 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
     }
   });
   });
 
 
+  it('defaults background task concurrency limits to disabled', () => {
+    const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.concurrency).toEqual({
+        defaultConcurrency: 0,
+        providerConcurrency: {},
+        modelConcurrency: {},
+      });
+    }
+  });
+
+  it('accepts default, provider, and model concurrency limits', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: {
+        concurrency: {
+          defaultConcurrency: 2,
+          providerConcurrency: { openai: 3 },
+          modelConcurrency: { 'openai/gpt-5.6-luna': 1 },
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
+
+  it('rejects invalid background task concurrency limits', () => {
+    for (const concurrency of [
+      { defaultConcurrency: -1 },
+      { defaultConcurrency: 1001 },
+      { defaultConcurrency: 1.5 },
+      { providerConcurrency: { openai: 0 } },
+      { modelConcurrency: { 'openai/gpt-5.6-luna': 1.5 } },
+    ]) {
+      expect(
+        PluginConfigSchema.safeParse({ backgroundJobs: { concurrency } })
+          .success,
+      ).toBe(false);
+    }
+  });
+
   it('accepts the documented wall-clock supervisor bounds', () => {
   it('accepts the documented wall-clock supervisor bounds', () => {
     expect(
     expect(
       PluginConfigSchema.safeParse({
       PluginConfigSchema.safeParse({

+ 34 - 0
src/config/schema.ts

@@ -174,6 +174,39 @@ export const InterviewConfigSchema = z.object({
 
 
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
 
+const ConcurrencyLimitSchema = z.number().int().min(1).max(1000);
+
+export const BackgroundTaskConcurrencyConfigSchema = z
+  .object({
+    defaultConcurrency: z
+      .number()
+      .int()
+      .min(0)
+      .max(1000)
+      .default(0)
+      .describe(
+        'Maximum concurrently running native background tasks. 0 disables the default cap.',
+      ),
+    providerConcurrency: z
+      .record(z.string().min(1), ConcurrencyLimitSchema)
+      .default({})
+      .describe('Per-provider concurrency caps keyed by provider ID.'),
+    modelConcurrency: z
+      .record(z.string().min(1), ConcurrencyLimitSchema)
+      .default({})
+      .describe('Per-model concurrency caps keyed by provider/model ID.'),
+  })
+  .strict()
+  .default({
+    defaultConcurrency: 0,
+    providerConcurrency: {},
+    modelConcurrency: {},
+  });
+
+export type BackgroundTaskConcurrencyConfig = z.infer<
+  typeof BackgroundTaskConcurrencyConfigSchema
+>;
+
 export const BackgroundJobsConfigSchema = z.object({
 export const BackgroundJobsConfigSchema = z.object({
   strategy: z
   strategy: z
     .enum(['latest', 'checkpoint-compatible'])
     .enum(['latest', 'checkpoint-compatible'])
@@ -231,6 +264,7 @@ export const BackgroundJobsConfigSchema = z.object({
     .describe(
     .describe(
       'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
       'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
     ),
     ),
+  concurrency: BackgroundTaskConcurrencyConfigSchema,
   waitForUserGuard: z
   waitForUserGuard: z
     .boolean()
     .boolean()
     .default(true)
     .default(true)

+ 3 - 0
src/hooks/task-session-manager/event-router.ts

@@ -287,6 +287,8 @@ export async function handleEvent(
     >;
     >;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    bindConcurrencyTicket?: (taskID: string, pending: PendingTaskCall) => void;
+    releaseConcurrencyTask?: (taskID: string) => void;
     observeSyntheticTerminalPart?: (part: unknown) => void;
     observeSyntheticTerminalPart?: (part: unknown) => void;
     revivedRunTracker?: RevivedRunTracker;
     revivedRunTracker?: RevivedRunTracker;
   },
   },
@@ -353,6 +355,7 @@ export async function handleEvent(
               background: false,
               background: false,
             });
             });
             pending.earlyRegisteredTaskID = record.taskID;
             pending.earlyRegisteredTaskID = record.taskID;
+            deps.bindConcurrencyTicket?.(record.taskID, pending);
             log(
             log(
               '[task-session-manager] tentative early board registration from session.created',
               '[task-session-manager] tentative early board registration from session.created',
               {
               {

+ 97 - 0
src/hooks/task-session-manager/index.test.ts

@@ -4,6 +4,7 @@ import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
 import {
   BackgroundJobBoard,
   BackgroundJobBoard,
   BackgroundJobSupervisor,
   BackgroundJobSupervisor,
+  BackgroundTaskConcurrency,
   createInternalAgentTextPart,
   createInternalAgentTextPart,
   getBackgroundJobLifecycleLedger,
   getBackgroundJobLifecycleLedger,
   SLIM_INTERNAL_INITIATOR_MARKER,
   SLIM_INTERNAL_INITIATOR_MARKER,
@@ -114,6 +115,8 @@ type HookOptions = {
   willAttemptFallback?: (sessionID: string) => boolean;
   willAttemptFallback?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
   coordinator?: SessionLifecycle;
   backgroundJobSupervisor?: BackgroundJobSupervisor;
   backgroundJobSupervisor?: BackgroundJobSupervisor;
+  backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+  getModelForAgent?: (agentType: string) => string | undefined;
 };
 };
 
 
 function createHook(options?: HookOptions) {
 function createHook(options?: HookOptions) {
@@ -137,6 +140,8 @@ function createHook(options?: HookOptions) {
       readContextMaxFiles: options?.readContextMaxFiles,
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
       backgroundJobBoard: options?.backgroundJobBoard,
       backgroundJobSupervisor: options?.backgroundJobSupervisor,
       backgroundJobSupervisor: options?.backgroundJobSupervisor,
+      backgroundTaskConcurrency: options?.backgroundTaskConcurrency,
+      getModelForAgent: options?.getModelForAgent,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       isFallbackInProgress: options?.isFallbackInProgress,
       isFallbackInProgress: options?.isFallbackInProgress,
@@ -236,6 +241,98 @@ describe('task-session-manager hook', () => {
     resetUserWaitGateForTests();
     resetUserWaitGateForTests();
   });
   });
 
 
+  test('queues background task admission until an earlier task releases its slot', async () => {
+    const concurrency = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    const { hook } = createHook({
+      backgroundTaskConcurrency: concurrency,
+      getModelForAgent: () => 'openai/fast',
+    });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          background: true,
+          subagent_type: 'explorer',
+          description: 'first task',
+        },
+      },
+    );
+
+    const secondAdmission = hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        args: {
+          background: true,
+          subagent_type: 'fixer',
+          description: 'second task',
+        },
+      },
+    );
+    await Promise.resolve();
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 1 });
+
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: taskLaunchOutput('ses_first') },
+    );
+    concurrency.releaseTask('ses_first');
+    await secondAdmission;
+
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+  });
+
+  test('exempts managed-task sessions from background admission (nested orchestration)', async () => {
+    const concurrency = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    // A session that is itself a managed background task.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'nested orchestrator',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      backgroundTaskConcurrency: concurrency,
+      getModelForAgent: () => 'openai/fast',
+    });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          background: true,
+          subagent_type: 'explorer',
+          description: 'outer task',
+        },
+      },
+    );
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+
+    // The only slot is taken, so a non-exempt caller would queue here and
+    // never be admitted while the managed child stays blocked on itself.
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'ses_child', callID: 'call-2' },
+      {
+        args: {
+          background: true,
+          subagent_type: 'librarian',
+          description: 'nested task',
+        },
+      },
+    );
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+  });
+
   test('ignores messages without OpenCode info or parts', async () => {
   test('ignores messages without OpenCode info or parts', async () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({

+ 12 - 0
src/hooks/task-session-manager/index.ts

@@ -4,6 +4,7 @@ import {
   type BackgroundJobExecution,
   type BackgroundJobExecution,
   type BackgroundJobStore,
   type BackgroundJobStore,
   type BackgroundJobSupervisor,
   type BackgroundJobSupervisor,
+  type BackgroundTaskConcurrency,
   clearBackgroundJobSuppression,
   clearBackgroundJobSuppression,
   deriveFullObjective,
   deriveFullObjective,
   deriveTaskSessionLabel,
   deriveTaskSessionLabel,
@@ -152,6 +153,8 @@ export function createTaskSessionManagerHook(
     readContextMaxFiles?: number;
     readContextMaxFiles?: number;
     backgroundJobBoard?: BackgroundJobStore;
     backgroundJobBoard?: BackgroundJobStore;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+    getModelForAgent?: (agentType: string) => string | undefined;
     shouldManageSession: (sessionID: string) => boolean;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
     /** Register a session as orchestrator when the transform hook detects
      *  an orchestrator message but the session isn't in the agent map yet. */
      *  an orchestrator message but the session isn't in the agent map yet. */
@@ -281,6 +284,7 @@ export function createTaskSessionManagerHook(
       // lose track of the task and report it as cancelled even though the
       // lose track of the task and report it as cancelled even though the
       // oracle actually completed.
       // oracle actually completed.
       if (!options.isFallbackInProgress?.(sessionId)) {
       if (!options.isFallbackInProgress?.(sessionId)) {
+        options.backgroundTaskConcurrency?.releaseTask(sessionId);
         options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
         options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
         const hardTimedOut =
         const hardTimedOut =
           backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
           backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
@@ -404,6 +408,8 @@ export function createTaskSessionManagerHook(
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
         backgroundJobBoard,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        backgroundTaskConcurrency: options.backgroundTaskConcurrency,
+        getModelForAgent: options.getModelForAgent,
         pendingCallTracker,
         pendingCallTracker,
         taskContextTracker,
         taskContextTracker,
         getLifecycleEpoch: () => rehydrateState.nextEpoch,
         getLifecycleEpoch: () => rehydrateState.nextEpoch,
@@ -417,6 +423,10 @@ export function createTaskSessionManagerHook(
         directory: _ctx.directory,
         directory: _ctx.directory,
         backgroundJobBoard,
         backgroundJobBoard,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        bindConcurrencyTicket: (taskID, pending) =>
+          pending.concurrencyTicket?.bind(taskID),
+        releaseConcurrencyTask: (taskID) =>
+          options.backgroundTaskConcurrency?.releaseTask(taskID),
         recordLifecycleSuppression: (taskID) =>
         recordLifecycleSuppression: (taskID) =>
           recordBackgroundJobSuppression(backgroundJobBoard, taskID),
           recordBackgroundJobSuppression(backgroundJobBoard, taskID),
         pendingCallTracker,
         pendingCallTracker,
@@ -533,6 +543,8 @@ export function createTaskSessionManagerHook(
         pendingInjectedTerminalJobsByParent,
         pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        bindConcurrencyTicket: (taskID, pending) =>
+          pending.concurrencyTicket?.bind(taskID),
         observeSyntheticTerminalPart: (part) =>
         observeSyntheticTerminalPart: (part) =>
           observeSyntheticTerminalPart(injectionState, part),
           observeSyntheticTerminalPart(injectionState, part),
         revivedRunTracker: options.revivedRunTracker,
         revivedRunTracker: options.revivedRunTracker,

+ 3 - 0
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -1,4 +1,5 @@
 import type { BackgroundJobLease } from '../../utils/background-job-board';
 import type { BackgroundJobLease } from '../../utils/background-job-board';
+import type { BackgroundTaskConcurrencyTicket } from '../../utils/background-task-concurrency';
 
 
 export interface PendingTaskCall {
 export interface PendingTaskCall {
   callId: string;
   callId: string;
@@ -13,6 +14,7 @@ export interface PendingTaskCall {
   lifecycleEpoch: number;
   lifecycleEpoch: number;
   resumedTaskId?: string;
   resumedTaskId?: string;
   relaunchLease?: BackgroundJobLease;
   relaunchLease?: BackgroundJobLease;
+  concurrencyTicket?: BackgroundTaskConcurrencyTicket;
   earlyRegisteredTaskID?: string;
   earlyRegisteredTaskID?: string;
   earlyRegistrationRejected?: boolean;
   earlyRegistrationRejected?: boolean;
 }
 }
@@ -27,6 +29,7 @@ export function createPendingCallTracker(
 
 
   const releaseCallLease = (call: PendingTaskCall): void => {
   const releaseCallLease = (call: PendingTaskCall): void => {
     if (call.relaunchLease) options.releaseLease?.(call.relaunchLease);
     if (call.relaunchLease) options.releaseLease?.(call.relaunchLease);
+    call.concurrencyTicket?.releaseIfUnbound();
   };
   };
 
 
   return {
   return {

+ 32 - 3
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -8,6 +8,7 @@
 import type {
 import type {
   BackgroundJobStore,
   BackgroundJobStore,
   BackgroundJobSupervisor,
   BackgroundJobSupervisor,
+  BackgroundTaskConcurrency,
   ContextFile,
   ContextFile,
 } from '../../utils';
 } from '../../utils';
 import {
 import {
@@ -72,10 +73,14 @@ export async function handleToolExecuteBefore(
     backgroundJobBoard: BackgroundJobStore;
     backgroundJobBoard: BackgroundJobStore;
     pendingCallTracker: {
     pendingCallTracker: {
       add(call: PendingTaskCall): void;
       add(call: PendingTaskCall): void;
+      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+      release?(call: PendingTaskCall): void;
       pendingCallId(sessionID?: string, callID?: string): string;
       pendingCallId(sessionID?: string, callID?: string): string;
     };
     };
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+    getModelForAgent?: (agentType: string) => string | undefined;
     getLifecycleEpoch?: () => number;
     getLifecycleEpoch?: () => number;
   },
   },
 ): Promise<void> {
 ): Promise<void> {
@@ -215,10 +220,26 @@ export async function handleToolExecuteBefore(
 
 
   try {
   try {
     deps.pendingCallTracker.add(pendingCall);
     deps.pendingCallTracker.add(pendingCall);
-  } catch (error) {
-    if (pendingCall.relaunchLease) {
-      deps.backgroundJobBoard.releaseLease(pendingCall.relaunchLease);
+    if (pendingCall.background && deps.backgroundTaskConcurrency) {
+      // Nested orchestration exemption: a session that is itself a managed
+      // task already holds an admission slot. Waiting for another one while
+      // the queue is saturated would deadlock — this session could never
+      // finish, so its own slot could never be released.
+      const isManagedTask = deps.backgroundJobBoard
+        .taskIDs()
+        .has(input.sessionID);
+      if (!isManagedTask) {
+        const ticket = deps.backgroundTaskConcurrency.acquire({
+          model: deps.getModelForAgent?.(agentType),
+        });
+        pendingCall.concurrencyTicket = ticket;
+        await ticket.ready;
+      }
     }
     }
+  } catch (error) {
+    const tracked = deps.pendingCallTracker.take(pendingCall.callId);
+    if (tracked) deps.pendingCallTracker.release?.(tracked);
+    else pendingCall.concurrencyTicket?.releaseIfUnbound();
     throw error;
     throw error;
   }
   }
   log(
   log(
@@ -251,6 +272,8 @@ export async function handleToolExecuteAfter(
       prune(board: { taskIDs(): Set<string> }): void;
       prune(board: { taskIDs(): Set<string> }): void;
     };
     };
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    bindConcurrencyTicket?: (taskID: string, pending: PendingTaskCall) => void;
+    releaseConcurrencyTask?: (taskID: string) => void;
     /** Record direct task cleanup even when the store is a thin facade. */
     /** Record direct task cleanup even when the store is a thin facade. */
     recordLifecycleSuppression?: (taskID: string) => void;
     recordLifecycleSuppression?: (taskID: string) => void;
     /** Clear a deletion guard when a new native task output proves a run exists. */
     /** Clear a deletion guard when a new native task output proves a run exists. */
@@ -320,6 +343,7 @@ export async function handleToolExecuteAfter(
         deps,
         deps,
       );
       );
       if (!record) return;
       if (!record) return;
+      deps.bindConcurrencyTicket?.(record.taskID, pending);
       deps.clearRehydrateTombstone?.(launch.taskID);
       deps.clearRehydrateTombstone?.(launch.taskID);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
       log('[task-session-manager] background task launch registered', {
       log('[task-session-manager] background task launch registered', {
@@ -347,6 +371,7 @@ export async function handleToolExecuteAfter(
         deps,
         deps,
       );
       );
       if (!record) return;
       if (!record) return;
+      deps.bindConcurrencyTicket?.(record.taskID, pending);
       deps.clearRehydrateTombstone?.(status.taskID);
       deps.clearRehydrateTombstone?.(status.taskID);
       normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
       normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
@@ -357,6 +382,9 @@ export async function handleToolExecuteAfter(
         timedOut: status.timedOut,
         timedOut: status.timedOut,
         resultSummary: status.result,
         resultSummary: status.result,
       });
       });
+      if (updated?.state !== 'running') {
+        deps.releaseConcurrencyTask?.(status.taskID);
+      }
       log('[task-session-manager] foreground task status registered', {
       log('[task-session-manager] foreground task status registered', {
         taskID: status.taskID,
         taskID: status.taskID,
         alias: updated?.alias ?? record.alias,
         alias: updated?.alias ?? record.alias,
@@ -409,6 +437,7 @@ export async function handleToolExecuteAfter(
     if (pending.relaunchLease) {
     if (pending.relaunchLease) {
       deps.backgroundJobBoard.releaseLease(pending.relaunchLease);
       deps.backgroundJobBoard.releaseLease(pending.relaunchLease);
     }
     }
+    pending.concurrencyTicket?.releaseIfUnbound();
   }
   }
 }
 }
 
 

+ 10 - 0
src/index.ts

@@ -76,6 +76,7 @@ import {
   BackgroundJobBoard,
   BackgroundJobBoard,
   BackgroundJobCoordinator,
   BackgroundJobCoordinator,
   BackgroundJobSupervisor,
   BackgroundJobSupervisor,
+  BackgroundTaskConcurrency,
   createDisplayNameMentionRewriter,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
   resolveRuntimeAgentName,
 } from './utils';
 } from './utils';
@@ -251,6 +252,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
   let backgroundJobBoard: BackgroundJobBoard;
   let backgroundJobSupervisor: BackgroundJobSupervisor;
   let backgroundJobSupervisor: BackgroundJobSupervisor;
+  let backgroundTaskConcurrency: BackgroundTaskConcurrency;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let companionManager: CompanionManager;
   let taskCancelTools: ReturnType<typeof createCancelTaskTool>;
   let taskCancelTools: ReturnType<typeof createCancelTaskTool>;
@@ -365,6 +367,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMinLines: runtime.backgroundJobs.readContextMinLines,
       readContextMinLines: runtime.backgroundJobs.readContextMinLines,
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
     });
     });
+    backgroundTaskConcurrency = new BackgroundTaskConcurrency(
+      runtime.backgroundJobs.concurrency,
+    );
 
 
     // Initialize coordinator as the sole writer to the board
     // Initialize coordinator as the sole writer to the board
     const backgroundJobCoordinator = new BackgroundJobCoordinator(
     const backgroundJobCoordinator = new BackgroundJobCoordinator(
@@ -381,6 +386,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
     });
     backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
     backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
       backgroundJobSupervisor.onTerminal(record);
       backgroundJobSupervisor.onTerminal(record);
+      backgroundTaskConcurrency.releaseTask(record.taskID);
     });
     });
     revivedRunTracker = createRevivedRunTracker({
     revivedRunTracker = createRevivedRunTracker({
       input: ctx,
       input: ctx,
@@ -447,6 +453,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobSupervisor,
       backgroundJobSupervisor,
+      backgroundTaskConcurrency,
+      getModelForAgent: (agentType: string) =>
+        pickAgentModelRef(runtime.agent(agentType)?.model),
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {
       registerSessionAsOrchestrator: (sessionID) => {
@@ -1202,6 +1211,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await interviewManager.dispose();
       await interviewManager.dispose();
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
       clearTuiActivities();
       clearTuiActivities();
+      backgroundTaskConcurrency.dispose();
     },
     },
 
 
     'tool.execute.before': async (input, output) => {
     'tool.execute.before': async (input, output) => {

+ 128 - 0
src/utils/background-task-concurrency.test.ts

@@ -0,0 +1,128 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  BackgroundTaskConcurrency,
+  BackgroundTaskConcurrencyQueueCancelledError,
+} from './background-task-concurrency';
+
+const limited = (overrides = {}) =>
+  new BackgroundTaskConcurrency({
+    defaultConcurrency: 1,
+    providerConcurrency: {},
+    modelConcurrency: {},
+    ...overrides,
+  });
+
+describe('BackgroundTaskConcurrency', () => {
+  test('admits one task and queues the next task', async () => {
+    const scheduler = limited();
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/fast' });
+
+    await first.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 1, queued: 1 });
+
+    let secondReady = false;
+    void second.ready.then(() => {
+      secondReady = true;
+    });
+    await Promise.resolve();
+    expect(secondReady).toBe(false);
+
+    first.bind('ses_first');
+    scheduler.releaseTask('ses_first');
+    await second.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 1, queued: 0 });
+  });
+
+  test('preserves admission order under the default cap', async () => {
+    const scheduler = limited();
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/fast' });
+    const order: string[] = [];
+
+    await first.ready;
+    order.push('first');
+    first.bind('ses_first');
+    void second.ready.then(() => order.push('second'));
+    scheduler.releaseTask('ses_first');
+    await second.ready;
+
+    expect(order).toEqual(['first', 'second']);
+  });
+
+  test('applies model and provider caps alongside the default cap', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 3,
+      providerConcurrency: { openai: 1 },
+      modelConcurrency: { 'anthropic/slow': 1 },
+    });
+    const openaiFirst = scheduler.acquire({ model: 'openai/fast' });
+    const openaiSecond = scheduler.acquire({ model: 'openai/cheap' });
+    const anthropic = scheduler.acquire({ model: 'anthropic/slow' });
+
+    await openaiFirst.ready;
+    await anthropic.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 1 });
+
+    openaiFirst.bind('ses_openai');
+    scheduler.releaseTask('ses_openai');
+    await openaiSecond.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+  });
+
+  test('a model cap takes precedence over an unrestricted default', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 0,
+      providerConcurrency: {},
+      modelConcurrency: { 'openai/slow': 1 },
+    });
+    const first = scheduler.acquire({ model: 'openai/slow' });
+    const second = scheduler.acquire({ model: 'openai/slow' });
+
+    await first.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 1, queued: 1 });
+    first.release();
+    await second.ready;
+  });
+
+  test('does not cap tasks when all limits are disabled', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 0,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'anthropic/slow' });
+
+    await Promise.all([first.ready, second.ready]);
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+  });
+
+  test('releasing an unbound ticket removes it from the queue', async () => {
+    const scheduler = limited();
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/fast' });
+
+    await first.ready;
+    second.releaseIfUnbound();
+    await expect(second.ready).rejects.toBeInstanceOf(
+      BackgroundTaskConcurrencyQueueCancelledError,
+    );
+    expect(scheduler.snapshot()).toEqual({ active: 1, queued: 0 });
+    first.release();
+  });
+
+  test('dispose cancels queued tickets and releases active capacity', async () => {
+    const scheduler = limited();
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/fast' });
+
+    await first.ready;
+    scheduler.dispose();
+
+    await expect(second.ready).rejects.toBeInstanceOf(
+      BackgroundTaskConcurrencyQueueCancelledError,
+    );
+    expect(scheduler.snapshot()).toEqual({ active: 0, queued: 0 });
+  });
+});

+ 213 - 0
src/utils/background-task-concurrency.ts

@@ -0,0 +1,213 @@
+export interface BackgroundTaskConcurrencyConfig {
+  defaultConcurrency: number;
+  providerConcurrency: Readonly<Record<string, number>>;
+  modelConcurrency: Readonly<Record<string, number>>;
+}
+
+export interface BackgroundTaskConcurrencyRequest {
+  model?: string;
+}
+
+export interface BackgroundTaskConcurrencyTicket {
+  readonly ready: Promise<void>;
+  bind(taskID: string): void;
+  release(): void;
+  releaseIfUnbound(): void;
+}
+
+interface QueueEntry {
+  id: number;
+  model?: string;
+  provider?: string;
+  started: boolean;
+  released: boolean;
+  taskID?: string;
+  resolve: () => void;
+  reject: (error: Error) => void;
+}
+
+export class BackgroundTaskConcurrencyQueueCancelledError extends Error {
+  constructor() {
+    super('Background task concurrency queue was cancelled');
+    this.name = 'BackgroundTaskConcurrencyQueueCancelledError';
+  }
+}
+
+/**
+ * Process-local admission scheduler for native background task launches.
+ *
+ * Queued requests are admitted in order, but entries whose provider or model
+ * quota is saturated are skipped in favor of admittable later entries
+ * (FIFO with skip). A ticket owns capacity from the moment its `ready`
+ * promise resolves until the bound task reaches a terminal state. The job
+ * board still owns task lifecycle; this scheduler only controls admission.
+ */
+export class BackgroundTaskConcurrency {
+  private readonly waiting: QueueEntry[] = [];
+  private readonly active = new Set<QueueEntry>();
+  private readonly activeByProvider = new Map<string, number>();
+  private readonly activeByModel = new Map<string, number>();
+  private readonly activeByTaskID = new Map<string, QueueEntry>();
+  private nextID = 0;
+  private disposed = false;
+
+  constructor(private readonly config: BackgroundTaskConcurrencyConfig) {}
+
+  acquire(
+    request: BackgroundTaskConcurrencyRequest,
+  ): BackgroundTaskConcurrencyTicket {
+    let resolveReady!: () => void;
+    let rejectReady!: (error: Error) => void;
+    const ready = new Promise<void>((resolve, reject) => {
+      resolveReady = resolve;
+      rejectReady = reject;
+    });
+    const model = normalizeModel(request.model);
+    const entry: QueueEntry = {
+      id: ++this.nextID,
+      model,
+      provider: providerFromModel(model),
+      started: false,
+      released: false,
+      resolve: resolveReady,
+      reject: rejectReady,
+    };
+
+    if (this.disposed) {
+      entry.released = true;
+      rejectReady(new BackgroundTaskConcurrencyQueueCancelledError());
+    } else {
+      this.waiting.push(entry);
+      this.pump();
+    }
+
+    return {
+      ready,
+      bind: (taskID) => this.bind(entry, taskID),
+      release: () => this.release(entry),
+      releaseIfUnbound: () => {
+        if (entry.taskID === undefined) this.release(entry);
+      },
+    };
+  }
+
+  releaseTask(taskID: string): void {
+    const entry = this.activeByTaskID.get(taskID);
+    if (entry) this.release(entry);
+  }
+
+  dispose(): void {
+    if (this.disposed) return;
+    this.disposed = true;
+    for (const entry of [...this.waiting, ...this.active]) {
+      this.release(entry);
+    }
+  }
+
+  /** Test/diagnostic seam. */
+  snapshot(): { active: number; queued: number } {
+    return { active: this.active.size, queued: this.waiting.length };
+  }
+
+  private bind(entry: QueueEntry, taskID: string): void {
+    if (!entry.started || entry.released || !taskID) return;
+    if (entry.taskID === taskID) return;
+    if (entry.taskID !== undefined) {
+      this.activeByTaskID.delete(entry.taskID);
+    }
+    entry.taskID = taskID;
+    this.activeByTaskID.set(taskID, entry);
+  }
+
+  private pump(): void {
+    if (this.disposed) return;
+
+    while (true) {
+      const index = this.waiting.findIndex((entry) => this.canStart(entry));
+      if (index < 0) return;
+      const [entry] = this.waiting.splice(index, 1);
+      if (!entry || entry.released) continue;
+
+      entry.started = true;
+      this.active.add(entry);
+      increment(this.activeByProvider, entry.provider);
+      increment(this.activeByModel, entry.model);
+      entry.resolve();
+    }
+  }
+
+  private canStart(entry: QueueEntry): boolean {
+    const defaultLimit = enabledLimit(this.config.defaultConcurrency);
+    if (defaultLimit !== undefined && this.active.size >= defaultLimit) {
+      return false;
+    }
+
+    const providerLimit = entry.provider
+      ? enabledLimit(this.config.providerConcurrency[entry.provider])
+      : undefined;
+    if (
+      providerLimit !== undefined &&
+      (this.activeByProvider.get(entry.provider ?? '') ?? 0) >= providerLimit
+    ) {
+      return false;
+    }
+
+    const modelLimit = entry.model
+      ? enabledLimit(this.config.modelConcurrency[entry.model])
+      : undefined;
+    return (
+      modelLimit === undefined ||
+      (this.activeByModel.get(entry.model ?? '') ?? 0) < modelLimit
+    );
+  }
+
+  private release(entry: QueueEntry): void {
+    if (entry.released) return;
+    entry.released = true;
+
+    const waitingIndex = this.waiting.indexOf(entry);
+    if (waitingIndex >= 0) {
+      this.waiting.splice(waitingIndex, 1);
+      entry.reject(new BackgroundTaskConcurrencyQueueCancelledError());
+      this.pump();
+      return;
+    }
+
+    if (entry.started) {
+      this.active.delete(entry);
+      decrement(this.activeByProvider, entry.provider);
+      decrement(this.activeByModel, entry.model);
+    }
+    if (entry.taskID !== undefined) {
+      this.activeByTaskID.delete(entry.taskID);
+    }
+    this.pump();
+  }
+}
+
+function normalizeModel(model: string | undefined): string | undefined {
+  const value = model?.trim();
+  return value || undefined;
+}
+
+function providerFromModel(model: string | undefined): string | undefined {
+  if (!model) return undefined;
+  const slash = model.indexOf('/');
+  return slash > 0 ? model.slice(0, slash) : undefined;
+}
+
+function enabledLimit(limit: number | undefined): number | undefined {
+  return typeof limit === 'number' && limit > 0 ? limit : undefined;
+}
+
+function increment(map: Map<string, number>, key: string | undefined): void {
+  if (!key) return;
+  map.set(key, (map.get(key) ?? 0) + 1);
+}
+
+function decrement(map: Map<string, number>, key: string | undefined): void {
+  if (!key) return;
+  const next = (map.get(key) ?? 0) - 1;
+  if (next > 0) map.set(key, next);
+  else map.delete(key);
+}

+ 2 - 0
src/utils/codemap.md

@@ -25,6 +25,8 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 
 
 - **BackgroundJobSupervisor** (`background-job-supervisor.ts`): One-shot wall-clock deadline supervision for background task runs: deadline timer → abort → grace timer → terminal finalization. Owns only timer/generation/abort mechanics.
 - **BackgroundJobSupervisor** (`background-job-supervisor.ts`): One-shot wall-clock deadline supervision for background task runs: deadline timer → abort → grace timer → terminal finalization. Owns only timer/generation/abort mechanics.
 
 
+- **BackgroundTaskConcurrency** (`background-task-concurrency.ts`): Process-local admission scheduler for native background tasks. Tracks queued tickets and active provider/model capacity without owning Job Board lifecycle state.
+
 - **Runtime Session Status** (`session-runtime-status.ts`): Reads and validates the in-process OpenCode session-status map once per observation (5s bounded timeout). It distinguishes a valid absent session (`idle`) from malformed data or lookup failure (`unknown`) so lifecycle policy never treats schema drift as completion.
 - **Runtime Session Status** (`session-runtime-status.ts`): Reads and validates the in-process OpenCode session-status map once per observation (5s bounded timeout). It distinguishes a valid absent session (`idle`) from malformed data or lookup failure (`unknown`) so lifecycle policy never treats schema drift as completion.
 
 
 - **Session Metadata** (`session-metadata.ts`): `SessionMetadataStore` — bounded session → agent/directory map with LRU eviction that never evicts active orchestrator sessions.
 - **Session Metadata** (`session-metadata.ts`): `SessionMetadataStore` — bounded session → agent/directory map with LRU eviction that never evicts active orchestrator sessions.

+ 1 - 0
src/utils/index.ts

@@ -3,6 +3,7 @@ export * from './background-job-board';
 export * from './background-job-coordinator';
 export * from './background-job-coordinator';
 export * from './background-job-store';
 export * from './background-job-store';
 export * from './background-job-supervisor';
 export * from './background-job-supervisor';
+export * from './background-task-concurrency';
 export * from './internal-initiator';
 export * from './internal-initiator';
 export { initLogger, log } from './logger';
 export { initLogger, log } from './logger';
 export * from './polling';
 export * from './polling';