瀏覽代碼

feat(background-jobs): enhance background task concurrency management

HeZzz 2 周之前
父節點
當前提交
c0e429280d

+ 16 - 2
docs/background-orchestration.md

@@ -486,8 +486,22 @@ cannot modify a relaunched task.
 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.
+resolved cap is saturated are skipped in favor of admittable later requests.
+
+Only the most specific configured cap applies to a task: a model cap wins
+over a provider cap, which wins over the default cap. `0` means unlimited.
+So `modelConcurrency: {"openai/gpt-4o": 10}` permits 10 concurrent
+`openai/gpt-4o` tasks even when `defaultConcurrency` is lower; other OpenAI
+models fall back to `providerConcurrency` (or the default) instead.
+
+The scheduler keeps its accounting correct across two runtime events:
+- A task that switches models mid-flight (foreground model fallback or a
+  runtime `/model` change on the child session) moves its provider/model
+  accounting to the new model instead of keeping the admission-time model.
+- The scheduler is process-scoped, so a plugin re-init (the plugin factory
+  re-runs on config updates) preserves both running slots and queued
+  tickets. Deleting a parent orchestrator also releases its children's
+  admission slots, so capacity is never leaked by recursive-delete ordering.
 
 Sessions that are themselves managed tasks — a background subagent running
 its own nested `task(..., background: true)` calls — are exempt from

+ 17 - 9
docs/configuration.md

@@ -161,9 +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.concurrency.defaultConcurrency` | integer | `0` | Maximum concurrently running native background tasks. `0` means unlimited; 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 `0`–`1000`, where `0` means unlimited for that provider. The most specific configured cap wins: model > provider > default See [Background Job Management](#background-job-management). |
+| `backgroundJobs.concurrency.modelConcurrency` | object | `{}` | Per-model caps keyed by `provider/model` ID. Each value must be `0`–`1000`, where `0` means unlimited for that model. The most specific configured cap wins: model > provider > default 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. |
@@ -338,12 +338,20 @@ 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.
+not consume a provider request. `0` means unlimited.
+
+Only the most specific configured cap applies to a task, matching the
+reference implementation's priority: a model cap for the task's model wins
+over a provider cap for its provider, which wins over the default cap. For
+example, with `defaultConcurrency: 2`, `providerConcurrency: {"openai": 5}`
+and `modelConcurrency: {"openai/gpt-4o": 10}`, up to 10 `openai/gpt-4o`
+tasks run concurrently. Queued tasks are admitted in order among tasks whose
+resolved cap has capacity. Terminal completion, cancellation, failure,
+session deletion, and plugin disposal release the slot. A task that switches
+models mid-flight (e.g. foreground model fallback) moves its accounting to
+the new model. The scheduler is process-scoped: when the plugin re-inits on a
+config update, running slots and queued tickets survive, so admission state
+is not reset mid-run.
 
 Two behaviors to know about when concurrency is enabled:
 

+ 5 - 5
oh-my-opencode-slim.schema.json

@@ -1166,14 +1166,14 @@
           "properties": {
             "defaultConcurrency": {
               "default": 0,
-              "description": "Maximum concurrently running native background tasks. 0 disables the default cap.",
+              "description": "Maximum concurrently running native background tasks. 0 means unlimited.",
               "type": "integer",
               "minimum": 0,
               "maximum": 1000
             },
             "providerConcurrency": {
               "default": {},
-              "description": "Per-provider concurrency caps keyed by provider ID.",
+              "description": "Per-provider concurrency caps keyed by provider ID. The most specific configured cap wins: model > provider > default. 0 means unlimited for that provider.",
               "type": "object",
               "propertyNames": {
                 "type": "string",
@@ -1181,13 +1181,13 @@
               },
               "additionalProperties": {
                 "type": "integer",
-                "minimum": 1,
+                "minimum": 0,
                 "maximum": 1000
               }
             },
             "modelConcurrency": {
               "default": {},
-              "description": "Per-model concurrency caps keyed by provider/model ID.",
+              "description": "Per-model concurrency caps keyed by provider/model ID. The most specific configured cap wins: model > provider > default. 0 means unlimited for that model.",
               "type": "object",
               "propertyNames": {
                 "type": "string",
@@ -1195,7 +1195,7 @@
               },
               "additionalProperties": {
                 "type": "integer",
-                "minimum": 1,
+                "minimum": 0,
                 "maximum": 1000
               }
             }

+ 113 - 0
src/agents/index.test.ts

@@ -16,6 +16,7 @@ import {
   getAgentConfigs,
   getDisabledAgents,
   isSubagent,
+  resolveAgentConfigModel,
 } from './index';
 import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
 
@@ -1691,3 +1692,115 @@ describe('createAgents with malformed disabled_tools', () => {
     );
   });
 });
+
+describe('resolveAgentConfigModel', () => {
+  test('returns the explicit model when configured', () => {
+    const config: PluginConfig = {
+      agents: { oracle: { model: 'test/oracle-explicit' } },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'oracle')).toBe(
+      'test/oracle-explicit',
+    );
+  });
+
+  test('returns the primary model of an explicit model array', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { model: ['test/primary', 'test/fallback'] },
+      },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'oracle')).toBe(
+      'test/primary',
+    );
+  });
+
+  test('session inheritance resolves to no config model (parent session serves)', () => {
+    const config: PluginConfig = {
+      agents: { oracle: { inheritModelFrom: 'session' } },
+    };
+    expect(
+      resolveAgentConfigModel(runtimeFor(config), 'oracle'),
+    ).toBeUndefined();
+  });
+
+  test('orchestrator inheritance uses the configured orchestrator model', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: { model: 'test/orch' },
+        oracle: { inheritModelFrom: 'orchestrator' },
+      },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'oracle')).toBe(
+      'test/orch',
+    );
+  });
+
+  test('orchestrator inheritance without an orchestrator model leaves the config model-less', () => {
+    const config: PluginConfig = {
+      agents: { oracle: { inheritModelFrom: 'orchestrator' } },
+    };
+    expect(
+      resolveAgentConfigModel(runtimeFor(config), 'oracle'),
+    ).toBeUndefined();
+  });
+
+  test('fixer with no model inherits the librarian model', () => {
+    const config: PluginConfig = {
+      agents: { librarian: { model: 'anthropic/lib' } },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'fixer')).toBe(
+      'anthropic/lib',
+    );
+  });
+
+  test('fixer without librarian falls back to the preset primary model', () => {
+    const config: PluginConfig = {
+      preset: 'default',
+      presets: {
+        default: { oracle: { model: 'test/primary' } },
+      },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'fixer')).toBe(
+      'test/primary',
+    );
+  });
+
+  test('matches the final config model createAgents produces for the fixer case', () => {
+    const config: PluginConfig = {
+      agents: { librarian: { model: 'anthropic/lib' } },
+    };
+    const runtime = runtimeFor(config);
+    const fixer = createAgents(runtime).find((a) => a.name === 'fixer');
+    expect(fixer?.config.model).toBe('anthropic/lib');
+    expect(resolveAgentConfigModel(runtime, 'fixer')).toBe(fixer?.config.model);
+  });
+
+  test('resolves a dynamic councillor primary model from the active council preset', () => {
+    const config: PluginConfig = {
+      council: CouncilConfigSchema.parse({
+        presets: {
+          default: {
+            alpha: { model: ['openai/primary', 'google/fallback'] },
+          },
+        },
+      }),
+    };
+    expect(
+      resolveAgentConfigModel(runtimeFor(config), 'councillor-alpha'),
+    ).toBe('openai/primary');
+  });
+
+  test('resolves an ACP wrapper model for background admission', () => {
+    const config: PluginConfig = {
+      acpAgents: {
+        research: {
+          command: 'research-agent',
+          wrapperModel: 'anthropic/sonnet',
+        },
+      },
+    };
+    expect(resolveAgentConfigModel(runtimeFor(config), 'research')).toBe(
+      'anthropic/sonnet',
+    );
+  });
+});

+ 56 - 0
src/agents/index.ts

@@ -219,6 +219,62 @@ function applyModelInheritance(
   }
 }
 
+/**
+ * Resolve the model an agent's final config carries, mirroring the combined
+ * effect of `createAgents` fallbacks, `applyOverrides`, and the inheritance
+ * passes. Returns `undefined` exactly when the agent config ends up with NO
+ * model key — i.e. `inheritModelFrom: 'session'` (or `'orchestrator'` with no
+ * configured orchestrator model) — in which case OpenCode serves the agent
+ * with the parent session's current model.
+ *
+ * This is the single resolution source for both agent definition building and
+ * background-task admission, so provider/model concurrency accounting keys off
+ * the model the spawned subagent actually uses. Explicit `model` wins; then
+ * `inheritModelFrom`; then the historical fixer → librarian fallback; then
+ * the preset primary model; then the per-agent default.
+ */
+export function resolveAgentConfigModel(
+  runtime: RuntimeConfig,
+  name: string,
+): string | undefined {
+  const mergedAgents = runtime.agents();
+  const override = getOverrideFromAgents(mergedAgents, name);
+  if (override?.model !== undefined) {
+    return getPrimaryModelFromOverride(override);
+  }
+  if (override?.inheritModelFrom === 'session') {
+    return undefined;
+  }
+  if (override?.inheritModelFrom === 'orchestrator') {
+    return getPrimaryModelFromOverride(
+      getOverrideFromAgents(mergedAgents, 'orchestrator'),
+    );
+  }
+  // Dynamic councillors are defined outside `agents()` under the selected
+  // council preset. Their generated agent config carries the preset model.
+  if (name.startsWith('councillor-')) {
+    const seat = name.slice('councillor-'.length);
+    const preset =
+      runtime.council?.presets?.[runtime.council.default_preset ?? 'default'];
+    return preset?.[seat]?.models?.[0]?.id;
+  }
+  // ACP agents are generated from `acpAgents`; admission is for the wrapper
+  // session, so account for its configured wrapper model when present.
+  if (runtime.acpAgents[name]?.wrapperModel) {
+    return runtime.acpAgents[name].wrapperModel;
+  }
+  if (name === 'fixer') {
+    const librarianModel = getPrimaryModelFromOverride(
+      getOverrideFromAgents(mergedAgents, 'librarian'),
+    );
+    return librarianModel ?? runtime.primaryModel ?? DEFAULT_MODELS.librarian;
+  }
+  return (
+    runtime.primaryModel ??
+    (DEFAULT_MODELS as Record<string, string | undefined>)[name]
+  );
+}
+
 /**
  * Apply model inheritance to the final host agent config after the host layer
  * has been merged. This clears stale host models for `session` inheritance,

+ 25 - 1
src/config/schema.test.ts

@@ -275,7 +275,9 @@ describe('PluginConfigSchema backgroundJobs', () => {
       { defaultConcurrency: -1 },
       { defaultConcurrency: 1001 },
       { defaultConcurrency: 1.5 },
-      { providerConcurrency: { openai: 0 } },
+      { providerConcurrency: { openai: -1 } },
+      { providerConcurrency: { openai: 1.5 } },
+      { modelConcurrency: { 'openai/gpt-5.6-luna': -1 } },
       { modelConcurrency: { 'openai/gpt-5.6-luna': 1.5 } },
     ]) {
       expect(
@@ -285,6 +287,28 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
+  it('accepts zero as unlimited for provider and model caps', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: {
+        concurrency: {
+          defaultConcurrency: 2,
+          providerConcurrency: { openai: 0 },
+          modelConcurrency: { 'openai/gpt-5.6-luna': 0 },
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(
+        result.data.backgroundJobs?.concurrency?.providerConcurrency,
+      ).toEqual({ openai: 0 });
+      expect(result.data.backgroundJobs?.concurrency?.modelConcurrency).toEqual(
+        { 'openai/gpt-5.6-luna': 0 },
+      );
+    }
+  });
+
   it('accepts the documented wall-clock supervisor bounds', () => {
     expect(
       PluginConfigSchema.safeParse({

+ 7 - 3
src/config/schema.ts

@@ -174,7 +174,7 @@ export const InterviewConfigSchema = z.object({
 
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
-const ConcurrencyLimitSchema = z.number().int().min(1).max(1000);
+const ConcurrencyLimitSchema = z.number().int().min(0).max(1000);
 
 export const BackgroundTaskConcurrencyConfigSchema = z
   .object({
@@ -190,11 +190,15 @@ export const BackgroundTaskConcurrencyConfigSchema = z
     providerConcurrency: z
       .record(z.string().min(1), ConcurrencyLimitSchema)
       .default({})
-      .describe('Per-provider concurrency caps keyed by provider ID.'),
+      .describe(
+        'Per-provider concurrency caps keyed by provider ID. The most specific configured cap wins: model > provider > default. 0 means unlimited for that provider.',
+      ),
     modelConcurrency: z
       .record(z.string().min(1), ConcurrencyLimitSchema)
       .default({})
-      .describe('Per-model concurrency caps keyed by provider/model ID.'),
+      .describe(
+        'Per-model concurrency caps keyed by provider/model ID. The most specific configured cap wins: model > provider > default. 0 means unlimited for that model.',
+      ),
   })
   .strict()
   .default({

+ 10 - 0
src/hooks/foreground-fallback/index.ts

@@ -307,6 +307,13 @@ export class ForegroundFallbackManager {
    *   (one retry chance); 2 = exhausted again, aborted — stop intervening.
    *   Reset to 0 on successful responses or session deletion. */
   private readonly chainExhaustion = new Map<string, number>();
+  /** sessionID → notified when the session switched to a new model mid-flight
+   *  (e.g. after a fallback re-prompt). Lets the background-task admission
+   *  scheduler migrate provider/model accounting to the new model. */
+  private readonly onSessionModelChanged?: (
+    sessionID: string,
+    model: string,
+  ) => void;
 
   /** Exposed for task-session-manager: prevents idle reconciliation
    *  while a fallback abort/re-prompt is in flight for this session. */
@@ -366,7 +373,9 @@ export class ForegroundFallbackManager {
     /** Consecutive 429s tolerated on the same model before swap/abort. */
     private readonly maxRetries: number = 3,
     coordinator?: SessionLifecycle,
+    onSessionModelChanged?: (sessionID: string, model: string) => void,
   ) {
+    this.onSessionModelChanged = onSessionModelChanged;
     if (coordinator) {
       coordinator.onSessionDeleted((id) => {
         this.sessionModel.delete(id);
@@ -806,6 +815,7 @@ export class ForegroundFallbackManager {
       }
 
       this.sessionModel.set(sessionID, nextModel);
+      this.onSessionModelChanged?.(sessionID, nextModel);
       log('[foreground-fallback] switched to fallback model', {
         sessionID,
         agentName,

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

@@ -6657,6 +6657,55 @@ describe('task-session-manager hook', () => {
     expect(job?.state).toBe('running');
   });
 
+  test('deleting a parent releases its children admission slots (recursive-delete ordering)', async () => {
+    // A recursive delete can arrive parent-first, and a child mid-fallback
+    // is skipped entirely, so the parent's cleanup must release every
+    // child's slot itself — otherwise capacity is leaked forever.
+    const coordinator = new SessionLifecycle(() => {});
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'parent-1',
+      parentSessionID: 'grandparent',
+      agent: 'orchestrator',
+      description: 'parent orchestrator',
+    });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'child one',
+    });
+    board.registerLaunch({
+      taskID: 'child-2',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'child two',
+    });
+
+    const concurrency = new BackgroundTaskConcurrency({
+      defaultConcurrency: 10,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    // Every task holds an admission slot, as if it were running.
+    concurrency.restoreTask('parent-1', 'openai/orch');
+    concurrency.restoreTask('child-1', 'openai/child1');
+    concurrency.restoreTask('child-2', 'openai/child2');
+    expect(concurrency.snapshot()).toEqual({ active: 3, queued: 0 });
+
+    createHook({
+      backgroundJobBoard: board,
+      coordinator,
+      backgroundTaskConcurrency: concurrency,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => false,
+    });
+
+    coordinator.dispatchSessionDeleted('parent-1');
+
+    expect(concurrency.snapshot()).toEqual({ active: 0, queued: 0 });
+  });
+
   test('session.created early-registers board job so after-hook cancellation cannot orphan the child', async () => {
     // Reproduces #765: parent tool may be cancelled before tool.execute.after,
     // so the job never lands on the board. Early registration from

+ 29 - 1
src/hooks/task-session-manager/index.ts

@@ -57,6 +57,11 @@ function rehydrateHistoricalRunningTasks(
   shouldManageSession: (sessionID: string) => boolean,
   registerSessionAsOrchestrator?: (sessionID: string) => void,
   rehydrateTombstones?: ReadonlySet<string>,
+  backgroundTaskConcurrency?: BackgroundTaskConcurrency,
+  getModelForAgent?: (
+    agentType: string,
+    parentSessionID?: string,
+  ) => string | undefined,
 ): number {
   let rehydrated = 0;
   const managedOrchestratorSessionIDs = new Set<string>();
@@ -136,6 +141,16 @@ function rehydrateHistoricalRunningTasks(
         // to the first runtime-status reconciliation.
         now: 0,
       });
+      // Re-claim the admission slot this still-running task already holds.
+      // The scheduler is recreated on every plugin re-init (the factory re-
+      // runs on config updates), so without this restore a fresh scheduler
+      // would admit a second concurrent task past the configured cap. The
+      // model resolution mirrors admission so provider/model caps stay
+      // correct. Idempotent per taskID.
+      backgroundTaskConcurrency?.restoreTask(
+        taskID,
+        getModelForAgent?.(agent, parentSessionID),
+      );
       rehydrated += 1;
     }
   }
@@ -154,7 +169,10 @@ export function createTaskSessionManagerHook(
     backgroundJobBoard?: BackgroundJobStore;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundTaskConcurrency?: BackgroundTaskConcurrency;
-    getModelForAgent?: (agentType: string) => string | undefined;
+    getModelForAgent?: (
+      agentType: string,
+      parentSessionID?: 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. */
@@ -285,6 +303,14 @@ export function createTaskSessionManagerHook(
       // oracle actually completed.
       if (!options.isFallbackInProgress?.(sessionId)) {
         options.backgroundTaskConcurrency?.releaseTask(sessionId);
+        // The parent's child tasks are about to be dropped from the board.
+        // Normally each child's own session.deleted releases its admission
+        // slot, but a recursive delete can arrive parent-first, and a child
+        // mid-fallback is skipped entirely — release every child's slot here
+        // so none is left holding capacity forever. Idempotent per taskID.
+        for (const child of backgroundJobBoard.list(sessionId)) {
+          options.backgroundTaskConcurrency?.releaseTask(child.taskID);
+        }
         options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
         const hardTimedOut =
           backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
@@ -459,6 +485,8 @@ export function createTaskSessionManagerHook(
         options.shouldManageSession,
         options.registerSessionAsOrchestrator,
         rehydrateTombstones,
+        options.backgroundTaskConcurrency,
+        options.getModelForAgent,
       );
 
       for (const [messageIndex, message] of messages.entries()) {

+ 8 - 2
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -80,7 +80,10 @@ export async function handleToolExecuteBefore(
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
     backgroundJobSupervisor?: BackgroundJobSupervisor;
     backgroundTaskConcurrency?: BackgroundTaskConcurrency;
-    getModelForAgent?: (agentType: string) => string | undefined;
+    getModelForAgent?: (
+      agentType: string,
+      parentSessionID?: string,
+    ) => string | undefined;
     getLifecycleEpoch?: () => number;
   },
 ): Promise<void> {
@@ -230,7 +233,10 @@ export async function handleToolExecuteBefore(
         .has(input.sessionID);
       if (!isManagedTask) {
         const ticket = deps.backgroundTaskConcurrency.acquire({
-          model: deps.getModelForAgent?.(agentType),
+          model: deps.getModelForAgent?.(
+            agentType,
+            pendingCall.parentSessionId,
+          ),
         });
         pendingCall.concurrencyTicket = ticket;
         await ticket.ready;

+ 100 - 0
src/index.test.ts

@@ -396,6 +396,106 @@ describe('plugin TUI agent activity', () => {
   });
 });
 
+describe('background task admission model resolution', () => {
+  let originalEnv: typeof process.env;
+  let projectDir: string;
+  let hooks: Awaited<ReturnType<typeof plugin>> | undefined;
+
+  const createPlugin = () =>
+    plugin({
+      client: createPluginClient(async () => ({})),
+      directory: projectDir,
+      worktree: projectDir,
+      serverUrl: new URL('http://127.0.0.1:4098'),
+    } as never);
+
+  beforeEach(async () => {
+    originalEnv = { ...process.env };
+    projectDir = await mkdtemp('/tmp/oh-my-opencode-slim-concurrency-');
+    process.env = {
+      ...originalEnv,
+      OPENCODE_CONFIG_DIR: projectDir,
+      XDG_DATA_HOME: `${projectDir}/data`,
+      XDG_CACHE_HOME: `${projectDir}/cache`,
+      OPENCODE_LOG_DIR: `${projectDir}/logs`,
+    };
+    delete process.env.OH_MY_OPENCODE_SLIM_DISABLE;
+    await Bun.write(
+      `${projectDir}/oh-my-opencode-slim.json`,
+      JSON.stringify({
+        companion: { enabled: false },
+        backgroundJobs: {
+          concurrency: {
+            defaultConcurrency: 0,
+            providerConcurrency: { openai: 1 },
+          },
+        },
+        agents: { fixer: { inheritModelFrom: 'session' } },
+      }),
+    );
+    hooks = await createPlugin();
+  });
+
+  afterEach(async () => {
+    await hooks?.dispose?.();
+    process.env = originalEnv;
+    await rm(projectDir, { recursive: true, force: true });
+  });
+
+  test('chat.message records the session model so session-inheriting tasks queue behind the parent provider cap', async () => {
+    // chat.message fires before message.updated and carries the message's
+    // model. Without recording it, a session-inheriting fixer task would be
+    // admitted with no model (default tier, no provider cap).
+    await hooks?.['chat.message']?.(
+      {
+        sessionID: 'orchestrator-1',
+        agent: 'orchestrator',
+        model: { providerID: 'openai', modelID: 'gpt-4o' },
+      } as never,
+      {} as never,
+    );
+
+    const before = hooks?.['tool.execute.before'];
+    expect(before).toBeFunction();
+
+    const first = before?.(
+      { tool: 'task', sessionID: 'orchestrator-1', callID: 'call-1' } as never,
+      {
+        args: {
+          background: true,
+          subagent_type: 'fixer',
+          description: 'first task',
+        },
+      } as never,
+    );
+    const second = before?.(
+      { tool: 'task', sessionID: 'orchestrator-1', callID: 'call-2' } as never,
+      {
+        args: {
+          background: true,
+          subagent_type: 'fixer',
+          description: 'second task',
+        },
+      } as never,
+    );
+
+    // The first fixer task holds the single openai slot (resolved from the
+    // parent's model recorded by chat.message); the second must stay queued.
+    // (Slot release happens via board terminal outcomes, out of scope here.)
+    await first;
+    const outcome = await Promise.race([
+      second?.then(
+        () => 'admitted',
+        (e) => `rejected:${String(e)}`,
+      ),
+      new Promise<string>((resolve) =>
+        setTimeout(() => resolve('still-queued'), 100),
+      ),
+    ]);
+    expect(outcome).toBe('still-queued');
+  });
+});
+
 describe('plugin config model inheritance', () => {
   let originalEnv: typeof process.env;
   const configDirs: string[] = [];

+ 55 - 5
src/index.ts

@@ -4,6 +4,7 @@ import {
   createAgents,
   getAgentConfigs,
   isSubagent,
+  resolveAgentConfigModel,
 } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
@@ -76,8 +77,9 @@ import {
   BackgroundJobBoard,
   BackgroundJobCoordinator,
   BackgroundJobSupervisor,
-  BackgroundTaskConcurrency,
+  type BackgroundTaskConcurrency,
   createDisplayNameMentionRewriter,
+  getBackgroundTaskConcurrency,
   resolveRuntimeAgentName,
 } from './utils';
 import type { ContextFile } from './utils/background-job-board';
@@ -367,7 +369,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMinLines: runtime.backgroundJobs.readContextMinLines,
       readContextMaxFiles: runtime.backgroundJobs.readContextMaxFiles,
     });
-    backgroundTaskConcurrency = new BackgroundTaskConcurrency(
+    backgroundTaskConcurrency = getBackgroundTaskConcurrency(
+      ctx.directory,
       runtime.backgroundJobs.concurrency,
     );
 
@@ -440,6 +443,11 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ctx,
       runtime.fallback.maxRetries,
       sessionLifecycle,
+      // A managed background-task session switching models mid-flight must
+      // move its admission accounting (provider/model caps) to the new
+      // model. No-op for unknown/non-task sessions; idempotent per model.
+      (sessionID, model) =>
+        backgroundTaskConcurrency.migrateTask(sessionID, model),
     );
 
     deepworkCommandHook = createDeepworkCommandHook();
@@ -454,8 +462,16 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobSupervisor,
       backgroundTaskConcurrency,
-      getModelForAgent: (agentType: string) =>
-        pickAgentModelRef(runtime.agent(agentType)?.model),
+      getModelForAgent: (agentType: string, parentSessionID?: string) =>
+        // The model the spawned subagent's config actually carries — the
+        // same resolution createAgents/final config use (explicit model,
+        // inheritModelFrom, fixer→librarian, preset primary). When the agent
+        // config ends up model-less (session inheritance), OpenCode serves
+        // the parent session's current model, tracked per session here.
+        resolveAgentConfigModel(runtime, agentType) ??
+        (parentSessionID
+          ? sessionMetadata.getModel(parentSessionID)
+          : undefined),
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {
@@ -1082,6 +1098,18 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
             : typeof info?.model?.modelID === 'string'
               ? info.model.modelID
               : undefined;
+        // Track each session's current model so background task admission
+        // can resolve the model a model-less subagent will inherit.
+        if (typeof info?.sessionID === 'string' && providerID && modelID) {
+          const model = `${providerID}/${modelID}`;
+          sessionMetadata.setModel(info.sessionID, model);
+          // Managed background-task sessions are identified by their session
+          // ID. If the model serving one changed (fallback re-prompt, runtime
+          // switch), migrate the admission accounting so provider/model caps
+          // keep tracking the model actually in use. No-op for other
+          // sessions and idempotent when the model is unchanged.
+          backgroundTaskConcurrency.migrateTask(info.sessionID, model);
+        }
         if (typeof info?.agent === 'string' && providerID && modelID) {
           const agentName = resolveRuntimeAgentName(runtime, info.agent);
           const model = `${providerID}/${modelID}`;
@@ -1211,7 +1239,12 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await interviewManager.dispose();
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
       clearTuiActivities();
-      backgroundTaskConcurrency.dispose();
+      // The concurrency scheduler is process-scoped and deliberately NOT
+      // disposed here: the plugin dispose hook also runs on re-inits
+      // (config update → Instance.dispose), and disposing it would drop the
+      // admission state (running slots + queued tickets) that must survive
+      // for the new plugin generation. Its per-task slots are released as
+      // tasks reach terminal states; the process reaps it on exit.
     },
 
     'tool.execute.before': async (input, output) => {
@@ -1322,6 +1355,23 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           status: 'busy',
         });
       }
+
+      // chat.message carries the model selected for this message, and it
+      // fires before the message.updated event that the event hook relies
+      // on. Recording it here closes the early window where a session-
+      // inheriting background task could be admitted before its parent's
+      // model is known — admission then resolves the correct provider/model
+      // cap immediately.
+      const messageModel = input.model ?? output?.message?.model;
+      if (
+        messageModel &&
+        typeof messageModel.providerID === 'string' &&
+        typeof messageModel.modelID === 'string'
+      ) {
+        const model = `${messageModel.providerID}/${messageModel.modelID}`;
+        sessionMetadata.setModel(input.sessionID, model);
+        backgroundTaskConcurrency.migrateTask(input.sessionID, model);
+      }
       taskSessionManagerHook.observeChatMessage(input, output);
       orchestratorWakeScheduler.observeChatMessage(input, output);
     },

+ 278 - 1
src/utils/background-task-concurrency.test.ts

@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test';
 import {
   BackgroundTaskConcurrency,
   BackgroundTaskConcurrencyQueueCancelledError,
+  getBackgroundTaskConcurrency,
+  resetBackgroundTaskConcurrencyForTests,
 } from './background-task-concurrency';
 
 const limited = (overrides = {}) =>
@@ -50,7 +52,7 @@ describe('BackgroundTaskConcurrency', () => {
     expect(order).toEqual(['first', 'second']);
   });
 
-  test('applies model and provider caps alongside the default cap', async () => {
+  test('applies provider and model caps for their own keys', async () => {
     const scheduler = new BackgroundTaskConcurrency({
       defaultConcurrency: 3,
       providerConcurrency: { openai: 1 },
@@ -85,6 +87,46 @@ describe('BackgroundTaskConcurrency', () => {
     await second.ready;
   });
 
+  test('the most specific configured cap wins: model > provider > default', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: { openai: 1 },
+      modelConcurrency: { 'openai/gpt-4o': 3 },
+    });
+    const first = scheduler.acquire({ model: 'openai/gpt-4o' });
+    const second = scheduler.acquire({ model: 'openai/gpt-4o' });
+    const third = scheduler.acquire({ model: 'openai/gpt-4o' });
+
+    await Promise.all([first.ready, second.ready, third.ready]);
+    expect(scheduler.snapshot()).toEqual({ active: 3, queued: 0 });
+  });
+
+  test('a provider cap overrides the default cap', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: { openai: 3 },
+      modelConcurrency: {},
+    });
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/cheap' });
+
+    await Promise.all([first.ready, second.ready]);
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+  });
+
+  test('a provider cap of zero means unlimited for that provider', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: { openai: 0 },
+      modelConcurrency: {},
+    });
+    const first = scheduler.acquire({ model: 'openai/fast' });
+    const second = scheduler.acquire({ model: 'openai/fast' });
+
+    await Promise.all([first.ready, second.ready]);
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+  });
+
   test('does not cap tasks when all limits are disabled', async () => {
     const scheduler = new BackgroundTaskConcurrency({
       defaultConcurrency: 0,
@@ -98,6 +140,84 @@ describe('BackgroundTaskConcurrency', () => {
     expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
   });
 
+  test('restoreTask reclaims a slot for an already-running task and is idempotent', async () => {
+    const scheduler = limited();
+    scheduler.restoreTask('ses_running', 'openai/fast');
+    // A second restore for the same task must not double-count the slot.
+    scheduler.restoreTask('ses_running', 'openai/fast');
+
+    const next = scheduler.acquire({ model: 'openai/fast' });
+    let nextReady = false;
+    void next.ready.then(() => {
+      nextReady = true;
+    });
+    await Promise.resolve();
+    expect(nextReady).toBe(false);
+
+    scheduler.releaseTask('ses_running');
+    await next.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 1, queued: 0 });
+  });
+
+  test('restored tasks are accounted against their resolved provider/model cap', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 10,
+      providerConcurrency: { openai: 1 },
+      modelConcurrency: {},
+    });
+    scheduler.restoreTask('ses_running', 'openai/gpt-4o');
+
+    const next = scheduler.acquire({ model: 'openai/cheap' });
+    let nextReady = false;
+    void next.ready.then(() => {
+      nextReady = true;
+    });
+    await Promise.resolve();
+    expect(nextReady).toBe(false);
+
+    scheduler.releaseTask('ses_running');
+    await next.ready;
+  });
+
+  test('migrateTask moves provider accounting when a task switches models', async () => {
+    const scheduler = new BackgroundTaskConcurrency({
+      defaultConcurrency: 10,
+      providerConcurrency: { openai: 1, google: 1 },
+      modelConcurrency: {},
+    });
+    const openai = scheduler.acquire({ model: 'openai/gpt-4o' });
+    await openai.ready;
+    openai.bind('ses_openai');
+
+    // A second openai task is blocked by the openai cap.
+    const blockedOpenai = scheduler.acquire({ model: 'openai/cheap' });
+    let openaiBlocked = false;
+    void blockedOpenai.ready.then(() => {
+      openaiBlocked = true;
+    });
+    await Promise.resolve();
+    expect(openaiBlocked).toBe(false);
+
+    // The openai task falls back to google: the openai slot frees and the
+    // google accounting now includes this task.
+    scheduler.migrateTask('ses_openai', 'google/gemini-pro');
+    await blockedOpenai.ready;
+    expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+
+    // Now the migrated task holds the single google slot.
+    const blockedGoogle = scheduler.acquire({ model: 'google/gemini-flash' });
+    let googleBlocked = false;
+    void blockedGoogle.ready.then(() => {
+      googleBlocked = true;
+    });
+    await Promise.resolve();
+    expect(googleBlocked).toBe(false);
+
+    scheduler.releaseTask('ses_openai');
+    await blockedGoogle.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' });
@@ -125,4 +245,161 @@ describe('BackgroundTaskConcurrency', () => {
     );
     expect(scheduler.snapshot()).toEqual({ active: 0, queued: 0 });
   });
+
+  describe('process-scoped shared instances', () => {
+    const config = (overrides = {}) => ({
+      defaultConcurrency: 1,
+      providerConcurrency: {},
+      modelConcurrency: {},
+      ...overrides,
+    });
+
+    test('returns the same instance and keeps running + queued state across re-inits', async () => {
+      resetBackgroundTaskConcurrencyForTests();
+      try {
+        const scheduler = getBackgroundTaskConcurrency('proj-a', config());
+        const first = scheduler.acquire({ model: 'openai/fast' });
+        await first.ready;
+        first.bind('ses_first');
+
+        const second = scheduler.acquire({ model: 'openai/fast' });
+        let secondReady = false;
+        void second.ready.then(() => {
+          secondReady = true;
+        });
+        await Promise.resolve();
+        expect(secondReady).toBe(false);
+
+        // Simulate a plugin re-init: the factory re-runs and re-requests the
+        // shared scheduler. Running slots AND queued tickets must survive —
+        // the queued ticket is NOT rejected.
+        const again = getBackgroundTaskConcurrency('proj-a', config());
+        expect(again).toBe(scheduler);
+        expect(scheduler.snapshot()).toEqual({ active: 1, queued: 1 });
+        expect(secondReady).toBe(false);
+
+        scheduler.releaseTask('ses_first');
+        await second.ready;
+        expect(secondReady).toBe(true);
+        expect(scheduler.snapshot()).toEqual({ active: 1, queued: 0 });
+      } finally {
+        resetBackgroundTaskConcurrencyForTests();
+      }
+    });
+
+    test('isolates schedulers per project directory', async () => {
+      resetBackgroundTaskConcurrencyForTests();
+      try {
+        const schedulerA = getBackgroundTaskConcurrency('proj-a', config());
+        const schedulerB = getBackgroundTaskConcurrency('proj-b', config());
+        expect(schedulerA).not.toBe(schedulerB);
+
+        const a = schedulerA.acquire({ model: 'openai/fast' });
+        await a.ready;
+        a.bind('ses_a_first');
+        schedulerA.acquire({ model: 'openai/fast' });
+        await Promise.resolve();
+        expect(schedulerA.snapshot()).toEqual({ active: 1, queued: 1 });
+
+        // Project B is unaffected by A's saturated queue and A's re-init.
+        const b = schedulerB.acquire({ model: 'openai/fast' });
+        await b.ready;
+        expect(schedulerB.snapshot()).toEqual({ active: 1, queued: 0 });
+
+        // A re-init for A must not mutate B's config either.
+        getBackgroundTaskConcurrency(
+          'proj-a',
+          config({ defaultConcurrency: 2 }),
+        );
+        const b2 = schedulerB.acquire({ model: 'openai/fast' });
+        let b2Ready = false;
+        void b2.ready.then(() => {
+          b2Ready = true;
+        });
+        await Promise.resolve();
+        expect(b2Ready).toBe(false);
+        expect(schedulerB.snapshot()).toEqual({ active: 1, queued: 1 });
+      } finally {
+        resetBackgroundTaskConcurrencyForTests();
+      }
+    });
+
+    test('updateConfig re-resolves queued tiers and re-pumps', async () => {
+      const scheduler = new BackgroundTaskConcurrency({
+        defaultConcurrency: 1,
+        providerConcurrency: {},
+        modelConcurrency: {},
+      });
+      const first = scheduler.acquire({ model: 'openai/fast' });
+      await first.ready;
+      first.bind('ses_first');
+      const second = scheduler.acquire({ model: 'openai/fast' });
+      await Promise.resolve();
+      expect(scheduler.snapshot()).toEqual({ active: 1, queued: 1 });
+
+      // Raising the default cap admits the queued task immediately.
+      scheduler.updateConfig({
+        defaultConcurrency: 2,
+        providerConcurrency: {},
+        modelConcurrency: {},
+      });
+      await second.ready;
+      expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+    });
+
+    test('updateConfig re-counts running tasks against a newly tightened provider cap', async () => {
+      // Two OpenAI tasks admitted while the provider was unrestricted, then
+      // the config tightens the openai cap to 1: the running tasks must now
+      // count against it, so a third openai task queues instead of starting.
+      const scheduler = new BackgroundTaskConcurrency({
+        defaultConcurrency: 10,
+        providerConcurrency: {},
+        modelConcurrency: {},
+      });
+      const first = scheduler.acquire({ model: 'openai/gpt-4o' });
+      const second = scheduler.acquire({ model: 'openai/cheap' });
+      await Promise.all([first.ready, second.ready]);
+      first.bind('ses_openai_1');
+      second.bind('ses_openai_2');
+      expect(scheduler.snapshot()).toEqual({ active: 2, queued: 0 });
+
+      scheduler.updateConfig({
+        defaultConcurrency: 10,
+        providerConcurrency: { openai: 1 },
+        modelConcurrency: {},
+      });
+
+      const third = scheduler.acquire({ model: 'openai/gpt-4o-mini' });
+      let thirdReady = false;
+      void third.ready.then(() => {
+        thirdReady = true;
+      });
+      await Promise.resolve();
+      expect(thirdReady).toBe(false);
+      expect(scheduler.snapshot()).toEqual({ active: 2, queued: 1 });
+
+      // Releasing one running openai task keeps the cap full (the other
+      // still runs); releasing both frees the slot for the queued task.
+      scheduler.releaseTask('ses_openai_2');
+      await Promise.resolve();
+      expect(thirdReady).toBe(false);
+
+      scheduler.releaseTask('ses_openai_1');
+      await third.ready;
+      expect(scheduler.snapshot()).toEqual({ active: 1, queued: 0 });
+    });
+
+    test('a disposed shared instance is replaced by a fresh one', () => {
+      resetBackgroundTaskConcurrencyForTests();
+      try {
+        const first = getBackgroundTaskConcurrency('proj-a', config());
+        first.dispose();
+        const second = getBackgroundTaskConcurrency('proj-a', config());
+        expect(second).not.toBe(first);
+        expect(second.isDisposed()).toBe(false);
+      } finally {
+        resetBackgroundTaskConcurrencyForTests();
+      }
+    });
+  });
 });

+ 225 - 33
src/utils/background-task-concurrency.ts

@@ -15,10 +15,18 @@ export interface BackgroundTaskConcurrencyTicket {
   releaseIfUnbound(): void;
 }
 
+type ConcurrencyTier = 'model' | 'provider' | 'default';
+
 interface QueueEntry {
   id: number;
   model?: string;
   provider?: string;
+  /** Resolved cap tier. Only ONE tier applies per task (model > provider > default). */
+  tier: ConcurrencyTier;
+  /** Key counted against for model/provider tiers (model ID or provider ID). */
+  key?: string;
+  /** Resolved cap for the tier; Infinity when the tier is unlimited (0). */
+  limit: number;
   started: boolean;
   released: boolean;
   taskID?: string;
@@ -36,22 +44,73 @@ export class BackgroundTaskConcurrencyQueueCancelledError extends Error {
 /**
  * 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.
+ * Limits follow the reference implementation's override semantics: a model
+ * cap for the task's model wins over a provider cap for its provider, which
+ * wins over the default cap — only the most specific configured cap applies.
+ * A configured value of `0` means unlimited for that key. Queued requests are
+ * admitted in order, but entries whose resolved tier 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.
+ *
+ * State is scoped to the scheduler instance. The plugin factory can re-run
+ * on config updates (see src/agents/index.ts), and the scheduler is created
+ * once per project through `getBackgroundTaskConcurrency` so admission state
+ * (running slots AND queued tickets) survives re-inits. `restoreTask` covers
+ * the one case the shared instance cannot: a genuine process restart that
+ * resumes a still-running task from persisted message history.
  */
 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 activeByKey = new Map<string, number>();
   private readonly activeByTaskID = new Map<string, QueueEntry>();
+  private activeDefault = 0;
   private nextID = 0;
   private disposed = false;
 
-  constructor(private readonly config: BackgroundTaskConcurrencyConfig) {}
+  constructor(private config: BackgroundTaskConcurrencyConfig) {}
+
+  /**
+   * Apply a new configuration to this instance (used when the plugin factory
+   * re-runs with changed config). Both running slots and queued tickets are
+   * re-resolved against the new config: active entries move their accounting
+   * to the tier their model now resolves to (so a newly lowered cap starts
+   * counting tasks that were admitted under an unlimited/looser config), and
+   * the queue re-pumps. Existing tasks are never terminated by a config
+   * change — a running task that now exceeds a tightened cap keeps running
+   * and blocks new admissions until it finishes.
+   */
+  updateConfig(config: BackgroundTaskConcurrencyConfig): void {
+    this.config = config;
+    for (const entry of this.active) {
+      const tier = resolveTier(this.config, entry.model);
+      if (
+        tier.tier === entry.tier &&
+        tier.key === entry.key &&
+        tier.limit === entry.limit
+      ) {
+        continue;
+      }
+      this.untrack(entry);
+      entry.tier = tier.tier;
+      entry.key = tier.key;
+      entry.limit = tier.limit;
+      this.track(entry);
+    }
+    for (const entry of this.waiting) {
+      const tier = resolveTier(this.config, entry.model);
+      entry.tier = tier.tier;
+      entry.key = tier.key;
+      entry.limit = tier.limit;
+    }
+    this.pump();
+  }
+
+  isDisposed(): boolean {
+    return this.disposed;
+  }
 
   acquire(
     request: BackgroundTaskConcurrencyRequest,
@@ -63,10 +122,14 @@ export class BackgroundTaskConcurrency {
       rejectReady = reject;
     });
     const model = normalizeModel(request.model);
+    const tier = resolveTier(this.config, model);
     const entry: QueueEntry = {
       id: ++this.nextID,
       model,
       provider: providerFromModel(model),
+      tier: tier.tier,
+      key: tier.key,
+      limit: tier.limit,
       started: false,
       released: false,
       resolve: resolveReady,
@@ -96,6 +159,61 @@ export class BackgroundTaskConcurrency {
     if (entry) this.release(entry);
   }
 
+  /**
+   * Claim a slot for a task that is already running. Used to restore the
+   * admission state after a plugin re-init (or a process restart that resumes
+   * a live run), where the fresh scheduler cannot know about tasks that were
+   * admitted by a previous generation. Idempotent: a task that already holds
+   * a slot is left untouched. Restores bypass the resolved caps because the
+   * task is already in flight — we are reconstructing reality, not admitting
+   * new work.
+   */
+  restoreTask(taskID: string, model?: string): void {
+    if (this.disposed || !taskID || this.activeByTaskID.has(taskID)) return;
+    const normalized = normalizeModel(model);
+    const tier = resolveTier(this.config, normalized);
+    const entry: QueueEntry = {
+      id: ++this.nextID,
+      model: normalized,
+      provider: providerFromModel(normalized),
+      tier: tier.tier,
+      key: tier.key,
+      limit: tier.limit,
+      started: true,
+      released: false,
+      taskID,
+      resolve: () => {},
+      reject: () => {},
+    };
+    this.active.add(entry);
+    this.activeByTaskID.set(taskID, entry);
+    this.track(entry);
+  }
+
+  /**
+   * Atomically move a running task's accounting from its admission
+   * model/provider to a new model. Keeps provider/model caps correct when a
+   * child session switches models mid-flight (foreground fallback, runtime
+   * model switch). No-op when the task is unknown or already on that model.
+   */
+  migrateTask(taskID: string, model: string | undefined): void {
+    const entry = this.activeByTaskID.get(taskID);
+    if (!entry || entry.released) return;
+    const nextModel = normalizeModel(model);
+    if (entry.model === nextModel) return;
+    const tier = resolveTier(this.config, nextModel);
+
+    this.untrack(entry);
+    entry.model = nextModel;
+    entry.provider = providerFromModel(nextModel);
+    entry.tier = tier.tier;
+    entry.key = tier.key;
+    entry.limit = tier.limit;
+    this.track(entry);
+    // Moving a task off a saturated key can free capacity for waiters.
+    this.pump();
+  }
+
   dispose(): void {
     if (this.disposed) return;
     this.disposed = true;
@@ -112,6 +230,13 @@ export class BackgroundTaskConcurrency {
   private bind(entry: QueueEntry, taskID: string): void {
     if (!entry.started || entry.released || !taskID) return;
     if (entry.taskID === taskID) return;
+    const existing = this.activeByTaskID.get(taskID);
+    if (existing && existing !== entry) {
+      // A restored slot already claims this taskID (e.g. the task was
+      // rehydrated after a re-init before this ticket got bound). Drop the
+      // restored slot so the admitted ticket becomes the single owner.
+      this.release(existing);
+    }
     if (entry.taskID !== undefined) {
       this.activeByTaskID.delete(entry.taskID);
     }
@@ -130,35 +255,33 @@ export class BackgroundTaskConcurrency {
 
       entry.started = true;
       this.active.add(entry);
-      increment(this.activeByProvider, entry.provider);
-      increment(this.activeByModel, entry.model);
+      this.track(entry);
       entry.resolve();
     }
   }
 
   private canStart(entry: QueueEntry): boolean {
-    const defaultLimit = enabledLimit(this.config.defaultConcurrency);
-    if (defaultLimit !== undefined && this.active.size >= defaultLimit) {
-      return false;
-    }
+    if (entry.limit === Infinity) return true;
+    if (entry.tier === 'default') return this.activeDefault < entry.limit;
+    return (this.activeByKey.get(entry.key ?? '') ?? 0) < entry.limit;
+  }
 
-    const providerLimit = entry.provider
-      ? enabledLimit(this.config.providerConcurrency[entry.provider])
-      : undefined;
-    if (
-      providerLimit !== undefined &&
-      (this.activeByProvider.get(entry.provider ?? '') ?? 0) >= providerLimit
-    ) {
-      return false;
+  private track(entry: QueueEntry): void {
+    if (entry.limit === Infinity) return;
+    if (entry.tier === 'default') {
+      this.activeDefault += 1;
+    } else {
+      increment(this.activeByKey, entry.key);
     }
+  }
 
-    const modelLimit = entry.model
-      ? enabledLimit(this.config.modelConcurrency[entry.model])
-      : undefined;
-    return (
-      modelLimit === undefined ||
-      (this.activeByModel.get(entry.model ?? '') ?? 0) < modelLimit
-    );
+  private untrack(entry: QueueEntry): void {
+    if (entry.limit === Infinity) return;
+    if (entry.tier === 'default') {
+      this.activeDefault -= 1;
+    } else {
+      decrement(this.activeByKey, entry.key);
+    }
   }
 
   private release(entry: QueueEntry): void {
@@ -175,8 +298,7 @@ export class BackgroundTaskConcurrency {
 
     if (entry.started) {
       this.active.delete(entry);
-      decrement(this.activeByProvider, entry.provider);
-      decrement(this.activeByModel, entry.model);
+      this.untrack(entry);
     }
     if (entry.taskID !== undefined) {
       this.activeByTaskID.delete(entry.taskID);
@@ -185,6 +307,75 @@ export class BackgroundTaskConcurrency {
   }
 }
 
+// ── Process-scoped shared instances ──────────────────────────────────────
+//
+// The plugin factory re-runs on every config update (Instance.dispose →
+// re-init). A scheduler created inside the factory would lose its running
+// slots AND its queued tickets on every re-init. These module-level instances
+// survive re-inits: the factory calls `getBackgroundTaskConcurrency` with the
+// (possibly changed) config, which reuses the instance for the same project.
+//
+// One instance per project directory: multiple plugin instances (different
+// OpenCode workspaces in one process) must not share a queue or overwrite
+// each other's caps. Only a genuine process restart resets them —
+// `restoreTask` (wired into historical run rehydration) then reclaims slots
+// for tasks still running.
+
+const schedulersByDirectory = new Map<string, BackgroundTaskConcurrency>();
+
+/**
+ * Return the process-scoped scheduler for a project directory, applying
+ * `config` when the instance already exists (plugin re-init). Instances are
+ * isolated per directory so concurrent plugin instances never share admission
+ * state. Recreates an instance after a dispose so a caller that tore the
+ * scheduler down (tests, genuine unload) gets a fresh one instead of a
+ * permanently cancelled instance.
+ */
+export function getBackgroundTaskConcurrency(
+  directory: string,
+  config: BackgroundTaskConcurrencyConfig,
+): BackgroundTaskConcurrency {
+  const key = directory || 'default';
+  const existing = schedulersByDirectory.get(key);
+  if (!existing || existing.isDisposed()) {
+    const scheduler = new BackgroundTaskConcurrency(config);
+    schedulersByDirectory.set(key, scheduler);
+    return scheduler;
+  }
+  existing.updateConfig(config);
+  return existing;
+}
+
+/** Test seam: drop all shared instances between tests. */
+export function resetBackgroundTaskConcurrencyForTests(): void {
+  schedulersByDirectory.clear();
+}
+
+/** Resolve the single applicable cap tier for a model (model > provider > default). */
+function resolveTier(
+  config: BackgroundTaskConcurrencyConfig,
+  model: string | undefined,
+): { tier: ConcurrencyTier; key?: string; limit: number } {
+  if (model !== undefined) {
+    const modelLimit = config.modelConcurrency[model];
+    if (modelLimit !== undefined) {
+      return { tier: 'model', key: model, limit: enabledLimit(modelLimit) };
+    }
+    const provider = providerFromModel(model);
+    if (provider !== undefined) {
+      const providerLimit = config.providerConcurrency[provider];
+      if (providerLimit !== undefined) {
+        return {
+          tier: 'provider',
+          key: provider,
+          limit: enabledLimit(providerLimit),
+        };
+      }
+    }
+  }
+  return { tier: 'default', limit: enabledLimit(config.defaultConcurrency) };
+}
+
 function normalizeModel(model: string | undefined): string | undefined {
   const value = model?.trim();
   return value || undefined;
@@ -196,8 +387,9 @@ function providerFromModel(model: string | undefined): string | undefined {
   return slash > 0 ? model.slice(0, slash) : undefined;
 }
 
-function enabledLimit(limit: number | undefined): number | undefined {
-  return typeof limit === 'number' && limit > 0 ? limit : undefined;
+/** 0 (and absent/negative) means unlimited; a positive value caps the tier. */
+function enabledLimit(limit: number | undefined): number {
+  return typeof limit === 'number' && limit > 0 ? limit : Infinity;
 }
 
 function increment(map: Map<string, number>, key: string | undefined): void {

+ 12 - 0
src/utils/session-metadata.ts

@@ -2,6 +2,7 @@ type SessionMetadataEviction = (sessionID: string) => void;
 
 export class SessionMetadataStore {
   readonly #agents = new Map<string, string>();
+  readonly #models = new Map<string, string>();
   readonly #directories = new Map<string, string>();
   readonly #insertionOrder = new Map<string, undefined>();
   readonly #activeOrchestratorSessionIDs = new Set<string>();
@@ -20,6 +21,15 @@ export class SessionMetadataStore {
     return this.#agents.get(sessionID);
   }
 
+  getModel(sessionID: string): string | undefined {
+    return this.#models.get(sessionID);
+  }
+
+  setModel(sessionID: string, model: string): void {
+    this.#models.set(sessionID, model);
+    this.#track(sessionID);
+  }
+
   getDirectory(sessionID: string): string | undefined {
     return this.#directories.get(sessionID);
   }
@@ -53,6 +63,7 @@ export class SessionMetadataStore {
 
   delete(sessionID: string): void {
     this.#agents.delete(sessionID);
+    this.#models.delete(sessionID);
     this.#directories.delete(sessionID);
     this.#insertionOrder.delete(sessionID);
     this.#activeOrchestratorSessionIDs.delete(sessionID);
@@ -83,6 +94,7 @@ export class SessionMetadataStore {
 
       this.#insertionOrder.delete(evictableSessionID);
       this.#agents.delete(evictableSessionID);
+      this.#models.delete(evictableSessionID);
       this.#directories.delete(evictableSessionID);
       this.#onEvict?.(evictableSessionID);
     }