Browse Source

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

HeZzz 2 weeks ago
parent
commit
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
 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
 
 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.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.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). |
 | `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. |
@@ -312,7 +315,16 @@ The wall-clock supervisor is separately opt-in and remains disabled unless
       "intervalMs": 300000
     },
     "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,
 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
 emit a deprecation warning and migrate its boolean value to
 `orchestratorWake.enabled`. An `orchestratorWake.enabled` value in the same

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

@@ -1156,6 +1156,52 @@
           "minimum": 1000,
           "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": {
           "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.",

+ 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)
 - `interview`: Interview feature configuration
 - `backgroundJobs`: Background job configuration
+- `backgroundJobs.concurrency`: Optional default, provider, and model caps for native background task admission
 - `fallback`: Failover/retry configuration
 - `council`: Council configuration with presets and execution modes
 - `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.strategy).toBe('latest');
+    expect(runtime.backgroundJobs.concurrency).toEqual({
+      defaultConcurrency: 0,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
     expect(runtime.fallback).toEqual({ enabled: true, maxRetries: 3 });
     expect(runtime.webfetch.enabled).toBe(true);
     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 },
   wallClockTimeoutMs: 0,
   abortGraceMs: 10_000,
+  concurrency: {
+    defaultConcurrency: 0,
+    providerConcurrency: {},
+    modelConcurrency: {},
+  },
   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', () => {
     expect(
       PluginConfigSchema.safeParse({

+ 34 - 0
src/config/schema.ts

@@ -174,6 +174,39 @@ export const InterviewConfigSchema = z.object({
 
 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({
   strategy: z
     .enum(['latest', 'checkpoint-compatible'])
@@ -231,6 +264,7 @@ export const BackgroundJobsConfigSchema = z.object({
     .describe(
       'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
     ),
+  concurrency: BackgroundTaskConcurrencyConfigSchema,
   waitForUserGuard: z
     .boolean()
     .default(true)

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

@@ -287,6 +287,8 @@ export async function handleEvent(
     >;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    bindConcurrencyTicket?: (taskID: string, pending: PendingTaskCall) => void;
+    releaseConcurrencyTask?: (taskID: string) => void;
     observeSyntheticTerminalPart?: (part: unknown) => void;
     revivedRunTracker?: RevivedRunTracker;
   },
@@ -353,6 +355,7 @@ export async function handleEvent(
               background: false,
             });
             pending.earlyRegisteredTaskID = record.taskID;
+            deps.bindConcurrencyTicket?.(record.taskID, pending);
             log(
               '[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 {
   BackgroundJobBoard,
   BackgroundJobSupervisor,
+  BackgroundTaskConcurrency,
   createInternalAgentTextPart,
   getBackgroundJobLifecycleLedger,
   SLIM_INTERNAL_INITIATOR_MARKER,
@@ -114,6 +115,8 @@ type HookOptions = {
   willAttemptFallback?: (sessionID: string) => boolean;
   coordinator?: SessionLifecycle;
   backgroundJobSupervisor?: BackgroundJobSupervisor;
+  backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+  getModelForAgent?: (agentType: string) => string | undefined;
 };
 
 function createHook(options?: HookOptions) {
@@ -137,6 +140,8 @@ function createHook(options?: HookOptions) {
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
       backgroundJobSupervisor: options?.backgroundJobSupervisor,
+      backgroundTaskConcurrency: options?.backgroundTaskConcurrency,
+      getModelForAgent: options?.getModelForAgent,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       registerSessionAsOrchestrator: options?.registerSessionAsOrchestrator,
       isFallbackInProgress: options?.isFallbackInProgress,
@@ -236,6 +241,98 @@ describe('task-session-manager hook', () => {
     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 () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

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

@@ -4,6 +4,7 @@ import {
   type BackgroundJobExecution,
   type BackgroundJobStore,
   type BackgroundJobSupervisor,
+  type BackgroundTaskConcurrency,
   clearBackgroundJobSuppression,
   deriveFullObjective,
   deriveTaskSessionLabel,
@@ -152,6 +153,8 @@ export function createTaskSessionManagerHook(
     readContextMaxFiles?: number;
     backgroundJobBoard?: BackgroundJobStore;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+    getModelForAgent?: (agentType: string) => string | undefined;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
      *  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
       // oracle actually completed.
       if (!options.isFallbackInProgress?.(sessionId)) {
+        options.backgroundTaskConcurrency?.releaseTask(sessionId);
         options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
         const hardTimedOut =
           backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
@@ -404,6 +408,8 @@ export function createTaskSessionManagerHook(
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        backgroundTaskConcurrency: options.backgroundTaskConcurrency,
+        getModelForAgent: options.getModelForAgent,
         pendingCallTracker,
         taskContextTracker,
         getLifecycleEpoch: () => rehydrateState.nextEpoch,
@@ -417,6 +423,10 @@ export function createTaskSessionManagerHook(
         directory: _ctx.directory,
         backgroundJobBoard,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        bindConcurrencyTicket: (taskID, pending) =>
+          pending.concurrencyTicket?.bind(taskID),
+        releaseConcurrencyTask: (taskID) =>
+          options.backgroundTaskConcurrency?.releaseTask(taskID),
         recordLifecycleSuppression: (taskID) =>
           recordBackgroundJobSuppression(backgroundJobBoard, taskID),
         pendingCallTracker,
@@ -533,6 +543,8 @@ export function createTaskSessionManagerHook(
         pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
+        bindConcurrencyTicket: (taskID, pending) =>
+          pending.concurrencyTicket?.bind(taskID),
         observeSyntheticTerminalPart: (part) =>
           observeSyntheticTerminalPart(injectionState, part),
         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 { BackgroundTaskConcurrencyTicket } from '../../utils/background-task-concurrency';
 
 export interface PendingTaskCall {
   callId: string;
@@ -13,6 +14,7 @@ export interface PendingTaskCall {
   lifecycleEpoch: number;
   resumedTaskId?: string;
   relaunchLease?: BackgroundJobLease;
+  concurrencyTicket?: BackgroundTaskConcurrencyTicket;
   earlyRegisteredTaskID?: string;
   earlyRegistrationRejected?: boolean;
 }
@@ -27,6 +29,7 @@ export function createPendingCallTracker(
 
   const releaseCallLease = (call: PendingTaskCall): void => {
     if (call.relaunchLease) options.releaseLease?.(call.relaunchLease);
+    call.concurrencyTicket?.releaseIfUnbound();
   };
 
   return {

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

@@ -8,6 +8,7 @@
 import type {
   BackgroundJobStore,
   BackgroundJobSupervisor,
+  BackgroundTaskConcurrency,
   ContextFile,
 } from '../../utils';
 import {
@@ -72,10 +73,14 @@ export async function handleToolExecuteBefore(
     backgroundJobBoard: BackgroundJobStore;
     pendingCallTracker: {
       add(call: PendingTaskCall): void;
+      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+      release?(call: PendingTaskCall): void;
       pendingCallId(sessionID?: string, callID?: string): string;
     };
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+    getModelForAgent?: (agentType: string) => string | undefined;
     getLifecycleEpoch?: () => number;
   },
 ): Promise<void> {
@@ -215,10 +220,26 @@ export async function handleToolExecuteBefore(
 
   try {
     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;
   }
   log(
@@ -251,6 +272,8 @@ export async function handleToolExecuteAfter(
       prune(board: { taskIDs(): Set<string> }): void;
     };
     backgroundJobSupervisor?: BackgroundJobSupervisor;
+    bindConcurrencyTicket?: (taskID: string, pending: PendingTaskCall) => void;
+    releaseConcurrencyTask?: (taskID: string) => void;
     /** Record direct task cleanup even when the store is a thin facade. */
     recordLifecycleSuppression?: (taskID: string) => void;
     /** Clear a deletion guard when a new native task output proves a run exists. */
@@ -320,6 +343,7 @@ export async function handleToolExecuteAfter(
         deps,
       );
       if (!record) return;
+      deps.bindConcurrencyTicket?.(record.taskID, pending);
       deps.clearRehydrateTombstone?.(launch.taskID);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
       log('[task-session-manager] background task launch registered', {
@@ -347,6 +371,7 @@ export async function handleToolExecuteAfter(
         deps,
       );
       if (!record) return;
+      deps.bindConcurrencyTicket?.(record.taskID, pending);
       deps.clearRehydrateTombstone?.(status.taskID);
       normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
       if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
@@ -357,6 +382,9 @@ export async function handleToolExecuteAfter(
         timedOut: status.timedOut,
         resultSummary: status.result,
       });
+      if (updated?.state !== 'running') {
+        deps.releaseConcurrencyTask?.(status.taskID);
+      }
       log('[task-session-manager] foreground task status registered', {
         taskID: status.taskID,
         alias: updated?.alias ?? record.alias,
@@ -409,6 +437,7 @@ export async function handleToolExecuteAfter(
     if (pending.relaunchLease) {
       deps.backgroundJobBoard.releaseLease(pending.relaunchLease);
     }
+    pending.concurrencyTicket?.releaseIfUnbound();
   }
 }
 

+ 10 - 0
src/index.ts

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