Ver código fonte

feat(task-session-manager): opt-in same-provider background-to-foreground conversion (#1179)

Aveer 3 dias atrás
pai
commit
f1d7347fbc

+ 32 - 0
docs/background-orchestration.md

@@ -547,6 +547,38 @@ state keeps its slot forever, and queued tasks as well as the orchestrator's
 opt-in wall-clock supervisor below so stalled tasks are eventually forced to
 a terminal state and release their slots.
 
+### Same-Provider Foreground Conversion
+
+`backgroundJobs.sameProviderPolicy` (see
+[Configuration](configuration.md#background-job-management)) is an opt-in
+per-provider policy for local inference backends that execute multiple
+logical agent sessions on one shared model runtime (one accelerator, one
+KV-context pool). Running a foreground parent and a same-provider background
+child concurrently on such a backend can reduce throughput from repeated
+model/KV context switching between the two large sessions:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "sameProviderPolicy": {
+      "lm-nexus": "foreground"
+    }
+  }
+}
+```
+
+When the parent session's current model and the child agent's resolved model
+both resolve to a provider configured with `"foreground"`, the
+`tool.execute.before` hook rewrites the explicit
+`task(..., background: true)` request to `background: false` before the
+pending call is created. The task then runs through the existing foreground
+path unchanged: it skips background concurrency admission (no semaphore
+slot), is not wall-clock supervised, executes synchronously on the host, and
+its status is registered through the existing foreground bookkeeping.
+Unconfigured providers, different providers, and undeterminable providers
+leave `background: true` untouched (fail-open); the default (omitted)
+behavior is unchanged.
+
 ### Opt-in Wall-clock Supervisor
 
 The plugin can apply a one-shot wall-clock deadline to native background task

+ 33 - 0
docs/configuration.md

@@ -165,6 +165,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `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.sameProviderPolicy` | object | `{}` | Opt-in per-provider policy keyed by provider ID; the only value is `"foreground"`. When the parent session's current model and the child agent's resolved model both resolve to a configured provider, an explicit `task(..., background: true)` call is converted to the existing foreground execution path. Unconfigured, different, or undeterminable providers keep background behavior. 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. |
@@ -362,6 +363,38 @@ 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.
 
+`sameProviderPolicy` is an opt-in per-provider policy for local inference
+backends that execute multiple logical agent sessions on one shared
+accelerator/model runtime. When a foreground parent and a same-provider
+background child run concurrently on such a backend, throughput can degrade
+from repeated model/KV context switching between the two large sessions.
+When the parent session's current model and the child agent's resolved model
+both resolve to a provider configured with `"foreground"`, the explicit
+`task(..., background: true)` request is converted to the existing foreground
+execution path:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "sameProviderPolicy": {
+      "lm-nexus": "foreground"
+    }
+  }
+}
+```
+
+- Same provider with `"foreground"` configured → the background request is
+  converted to foreground (no concurrency admission, no wall-clock
+  supervision, synchronous host execution).
+- Different providers → unchanged.
+- Provider not configured → unchanged.
+- Either provider undeterminable → unchanged (fail-open).
+
+Default (omitted) behavior is unchanged. This does not change
+`orchestratorWake` or `defaultConcurrency`/`providerConcurrency`/
+`modelConcurrency` semantics: a converted task simply bypasses background
+admission like any foreground task.
+
 Two behaviors to know about when concurrency is enabled:
 
 - Sessions that are themselves managed tasks (a background subagent

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

@@ -1213,6 +1213,19 @@
           },
           "additionalProperties": false
         },
+        "sameProviderPolicy": {
+          "default": {},
+          "description": "Opt-in per-provider policy for native background tasks: when the parent session and the child agent both resolve to a provider listed here with value \"foreground\", the background request is converted to foreground execution (existing foreground path, no concurrency admission). Unlisted or unknown providers keep background behavior. Default {} (no conversion).",
+          "type": "object",
+          "propertyNames": {
+            "type": "string",
+            "minLength": 1
+          },
+          "additionalProperties": {
+            "type": "string",
+            "const": "foreground"
+          }
+        },
         "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/runtime.ts

@@ -87,6 +87,7 @@ const DEFAULT_BACKGROUND_JOBS: BackgroundJobsConfig = {
     providerConcurrency: {},
     modelConcurrency: {},
   },
+  sameProviderPolicy: {},
   waitForUserGuard: true,
 };
 

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

@@ -431,4 +431,58 @@ describe('PluginConfigSchema backgroundJobs', () => {
       );
     }
   });
+
+  it('accepts sameProviderPolicy entries with the foreground policy', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: {
+        sameProviderPolicy: { 'lm-nexus': 'foreground' },
+      },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.sameProviderPolicy).toEqual({
+        'lm-nexus': 'foreground',
+      });
+    }
+  });
+
+  it('accepts an empty sameProviderPolicy map', () => {
+    const result = PluginConfigSchema.safeParse({
+      backgroundJobs: { sameProviderPolicy: {} },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.sameProviderPolicy).toEqual({});
+    }
+  });
+
+  it('leaves default behavior unchanged when sameProviderPolicy is omitted', () => {
+    const withDefaults = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+    expect(withDefaults.success).toBe(true);
+    if (withDefaults.success) {
+      expect(withDefaults.data.backgroundJobs?.sameProviderPolicy).toEqual({});
+    }
+
+    const absent = PluginConfigSchema.safeParse({});
+    expect(absent.success).toBe(true);
+    if (absent.success) {
+      expect(absent.data.backgroundJobs).toBeUndefined();
+    }
+  });
+
+  it('rejects invalid sameProviderPolicy values', () => {
+    for (const sameProviderPolicy of [
+      { foo: 'background' },
+      { foo: 1 },
+      'foreground',
+    ]) {
+      expect(
+        PluginConfigSchema.safeParse({
+          backgroundJobs: { sameProviderPolicy },
+        }).success,
+      ).toBe(false);
+    }
+  });
 });

+ 6 - 0
src/config/schema.ts

@@ -268,6 +268,12 @@ export const BackgroundJobsConfigSchema = z.object({
       'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
     ),
   concurrency: BackgroundTaskConcurrencyConfigSchema,
+  sameProviderPolicy: z
+    .record(z.string().min(1), z.literal('foreground'))
+    .default({})
+    .describe(
+      'Opt-in per-provider policy for native background tasks: when the parent session and the child agent both resolve to a provider listed here with value "foreground", the background request is converted to foreground execution (existing foreground path, no concurrency admission). Unlisted or unknown providers keep background behavior. Default {} (no conversion).',
+    ),
   waitForUserGuard: z
     .boolean()
     .default(true)

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

@@ -219,6 +219,12 @@ export function createTaskSessionManagerHook(
       agentType: string,
       parentSessionID?: string,
     ) => string | undefined;
+    /** Current "provider/model" for a session. Feeds same-provider
+     *  background conversion. */
+    getSessionModel?: (sessionID: string) => string | undefined;
+    /** Opt-in provider → "foreground" map for same-provider background
+     *  conversion. */
+    sameProviderPolicy?: Record<string, 'foreground'>;
     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. */
@@ -654,6 +660,8 @@ export function createTaskSessionManagerHook(
         backgroundJobSupervisor: options.backgroundJobSupervisor,
         backgroundTaskConcurrency: options.backgroundTaskConcurrency,
         getModelForAgent: options.getModelForAgent,
+        getSessionModel: options.getSessionModel,
+        sameProviderPolicy: options.sameProviderPolicy,
         pendingCallTracker,
         taskContextTracker,
         getLifecycleEpoch: () => rehydrateState.nextEpoch,

+ 262 - 0
src/hooks/task-session-manager/same-provider-integration.test.ts

@@ -0,0 +1,262 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import {
+  BackgroundJobBoard,
+  BackgroundTaskConcurrency,
+} from '../../utils';
+import { createTaskSessionManagerHook } from './index';
+
+// Route getClient back to _ctx.client so the _ctx.client.session mock works
+// through the v2 lookup path (mirrors index.test.ts).
+mock.module('../../utils/opencode-client', () => ({
+  getClient: (input: { client: unknown }) => input.client as never,
+}));
+
+const LM_NEXUS_MODEL = 'lm-nexus/Qwen3.8-27B';
+const SATELLITE_MODEL = 'opencode/muse';
+const OPENAI_MODEL = 'openai/gpt-5.2';
+const POLICY: Record<string, 'foreground'> = { 'lm-nexus': 'foreground' };
+
+type IntegrationHookOptions = {
+  backgroundJobBoard?: BackgroundJobBoard;
+  backgroundTaskConcurrency?: BackgroundTaskConcurrency;
+  getModelForAgent?: (
+    agentType: string,
+    parentSessionID?: string,
+  ) => string | undefined;
+  getSessionModel?: (sessionID: string) => string | undefined;
+  sameProviderPolicy?: Record<string, 'foreground'>;
+};
+
+type TaskSessionManagerHook =
+  ReturnType<typeof createTaskSessionManagerHook>;
+
+function createHook(options?: IntegrationHookOptions) {
+  return createTaskSessionManagerHook(
+    {
+      client: {
+        session: {
+          status: mock(async () => ({ data: {} })),
+        },
+      },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 2,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      backgroundJobBoard: options?.backgroundJobBoard,
+      backgroundTaskConcurrency: options?.backgroundTaskConcurrency,
+      getModelForAgent: options?.getModelForAgent,
+      getSessionModel: options?.getSessionModel,
+      sameProviderPolicy: options?.sameProviderPolicy,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+function taskArgs(): Record<string, unknown> {
+  return {
+    subagent_type: 'oracle',
+    description: 'same-provider task',
+    prompt: 'do work',
+    background: true,
+  };
+}
+
+async function callBefore(
+  hook: TaskSessionManagerHook,
+  callID: string,
+  args: Record<string, unknown>,
+): Promise<void> {
+  await hook['tool.execute.before'](
+    { tool: 'task', sessionID: 'ses_parent', callID },
+    { args },
+  );
+}
+
+/** Resolves 'settled' if the promise settles within the margin, else 'timeout'. */
+async function settleWithin(
+  promise: Promise<void>,
+  marginMs = 50,
+): Promise<'settled' | 'timeout'> {
+  let timer: ReturnType<typeof setTimeout> | undefined;
+  const timeout = new Promise<'timeout'>((resolve) => {
+    timer = setTimeout(() => resolve('timeout'), marginMs);
+  });
+  try {
+    return await Promise.race([
+      promise.then(() => 'settled' as const),
+      timeout,
+    ]);
+  } finally {
+    if (timer !== undefined) clearTimeout(timer);
+  }
+}
+
+describe('same-provider background-to-foreground conversion (hook level)', () => {
+  test('converts a same-provider background task to foreground', async () => {
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => LM_NEXUS_MODEL,
+    });
+
+    const args = taskArgs();
+    await callBefore(hook, 'c1', args);
+    expect(args.background).toBe(false);
+  });
+
+  test('leaves background true when the child resolves to a different (satellite) provider', async () => {
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => SATELLITE_MODEL,
+    });
+
+    const args = taskArgs();
+    await callBefore(hook, 'c1', args);
+    expect(args.background).toBe(true);
+  });
+
+  test('leaves background true when sameProviderPolicy is omitted', async () => {
+    const hook = createHook({
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => LM_NEXUS_MODEL,
+    });
+
+    const args = taskArgs();
+    await callBefore(hook, 'c1', args);
+    expect(args.background).toBe(true);
+  });
+
+  test('converted task skips concurrency admission while an unconverted background task waits on the saturated slot', async () => {
+    const concurrency = new BackgroundTaskConcurrency({
+      defaultConcurrency: 1,
+      providerConcurrency: {},
+      modelConcurrency: {},
+    });
+    // Saturate the single default slot with an in-flight task.
+    const inFlight = concurrency.acquire({ model: LM_NEXUS_MODEL });
+    await inFlight.ready;
+    inFlight.bind('task_in_flight');
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => LM_NEXUS_MODEL,
+      backgroundTaskConcurrency: concurrency,
+    });
+
+    // Converted task: no ticket is taken and the saturated slot is never
+    // awaited, so the before hook settles promptly.
+    const convertedArgs = taskArgs();
+    const convertedOutcome = await settleWithin(
+      callBefore(hook, 'c1', convertedArgs),
+    );
+    expect(convertedOutcome).toBe('settled');
+    expect(convertedArgs.background).toBe(false);
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 0 });
+
+    // Different-provider background task: not converted, admission queues
+    // behind the saturated slot and the before hook blocks on the ticket.
+    const blockingHook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => SATELLITE_MODEL,
+      backgroundTaskConcurrency: concurrency,
+    });
+    const blockingArgs = taskArgs();
+    const blockingCall = callBefore(blockingHook, 'c2', blockingArgs);
+    blockingCall.catch(() => {
+      // Disposal below rejects the queued ticket's ready promise.
+    });
+    const blockingOutcome = await settleWithin(blockingCall);
+    expect(blockingOutcome).toBe('timeout');
+    expect(blockingArgs.background).toBe(true);
+    expect(concurrency.snapshot()).toEqual({ active: 1, queued: 1 });
+    concurrency.dispose();
+  });
+
+  test('converted task follows the existing foreground bookkeeping path to a terminal board record', async () => {
+    const board = new BackgroundJobBoard({
+      maxReusablePerAgent: 2,
+      readContextMinLines: 10,
+      readContextMaxFiles: 8,
+    });
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => LM_NEXUS_MODEL,
+      backgroundJobBoard: board,
+    });
+
+    const args = taskArgs();
+    await callBefore(hook, 'c1', args);
+    expect(args.background).toBe(false);
+
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'ses_parent', callID: 'c1' },
+      {
+        output: [
+          'task_id: task_fg_1',
+          'state: completed',
+          '',
+          '<task_result>',
+          'work done',
+          '</task_result>',
+        ].join('\n'),
+      },
+    );
+
+    const record = board.get('task_fg_1');
+    expect(record).toBeDefined();
+    expect(record?.state).toBe('completed');
+    expect(record?.background).toBe(false);
+    expect(record?.parentSessionID).toBe('ses_parent');
+    expect(record?.agent).toBe('oracle');
+  });
+
+  test('decision follows the current parent model after a runtime /model switch', async () => {
+    let parentModel = LM_NEXUS_MODEL;
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => parentModel,
+      getModelForAgent: () => LM_NEXUS_MODEL,
+    });
+
+    const before = taskArgs();
+    await callBefore(hook, 'c1', before);
+    expect(before.background).toBe(false);
+
+    // Simulate a runtime /model switch: the parent session's current model
+    // now belongs to a different provider, so the same child no longer
+    // converts.
+    parentModel = OPENAI_MODEL;
+    const after = taskArgs();
+    await callBefore(hook, 'c2', after);
+    expect(after.background).toBe(true);
+  });
+
+  test('decision follows the resolved child model after a simulated preset reload', async () => {
+    let childModel = LM_NEXUS_MODEL;
+    const hook = createHook({
+      sameProviderPolicy: POLICY,
+      getSessionModel: () => LM_NEXUS_MODEL,
+      getModelForAgent: () => childModel,
+    });
+
+    const before = taskArgs();
+    await callBefore(hook, 'c1', before);
+    expect(before.background).toBe(false);
+
+    // Simulate a preset reload: the agent's resolved model now points at a
+    // satellite provider, so the decision follows the new value. (This
+    // documents resolver freshness, not the preset machinery itself.)
+    childModel = SATELLITE_MODEL;
+    const after = taskArgs();
+    await callBefore(hook, 'c2', after);
+    expect(after.background).toBe(true);
+  });
+});

+ 146 - 0
src/hooks/task-session-manager/same-provider-policy.test.ts

@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'bun:test';
+
+import { convertSameProviderBackgroundTask } from './same-provider-policy';
+
+const LM_NEXUS_MODEL = 'lm-nexus/Qwen3.8-27B';
+const LM_NEXUS_OTHER_MODEL = 'lm-nexus/Qwen3.5-9B';
+const OPENAI_MODEL = 'openai/gpt-5.2';
+const POLICY: Record<string, 'foreground'> = { 'lm-nexus': 'foreground' };
+
+interface RunInput {
+  parentModel?: string;
+  childModel?: string;
+  policy?: Record<string, 'foreground'>;
+}
+
+function run(
+  initial: { background?: unknown },
+  input: RunInput,
+): { result: ReturnType<typeof convertSameProviderBackgroundTask>; args: { background?: unknown } } {
+  const args: { background?: unknown } = { ...initial };
+  const result = convertSameProviderBackgroundTask({
+    agentType: 'oracle',
+    parentSessionID: 'ses_parent',
+    args,
+    policy: input.policy,
+    getParentModel: () => input.parentModel,
+    getChildModel: () => input.childModel,
+  });
+  return { result, args };
+}
+
+describe('convertSameProviderBackgroundTask', () => {
+  it('converts when parent and child share the opted-in provider', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_OTHER_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({
+      converted: true,
+      parentProvider: 'lm-nexus',
+      childProvider: 'lm-nexus',
+    });
+    expect(args.background).toBe(false);
+  });
+
+  it('converts when the child inherits the parent model (same model, shared KV state)', () => {
+    // A child agent without a configured model resolves to the parent's
+    // exact model string — providers are equal by construction, and it is
+    // literally the same model sharing the same runtime.
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_MODEL,
+      policy: POLICY,
+    });
+    expect(result.converted).toBe(true);
+    expect(args.background).toBe(false);
+  });
+
+  it('does not convert when the child resolves to a different provider', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: OPENAI_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('does not convert when the shared provider has no policy entry', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: OPENAI_MODEL,
+      childModel: OPENAI_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('does not convert with an empty policy map', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_MODEL,
+      policy: {},
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('does not convert when the policy option is undefined', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_MODEL,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('does not convert an explicit foreground task (background: false)', () => {
+    const { result, args } = run({ background: false }, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(false);
+  });
+
+  it('does not convert when the background flag is absent', () => {
+    const { result, args } = run({}, {
+      parentModel: LM_NEXUS_MODEL,
+      childModel: LM_NEXUS_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBeUndefined();
+  });
+
+  it('fail-open: does not convert when the parent model is unknown', () => {
+    const { result, args } = run({ background: true }, {
+      childModel: LM_NEXUS_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('fail-open: does not convert when the child model is unknown', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: LM_NEXUS_MODEL,
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+
+  it('fail-open: does not convert when a model string has no provider', () => {
+    const { result, args } = run({ background: true }, {
+      parentModel: 'Qwen3.8-27B',
+      childModel: 'Qwen3.8-27B',
+      policy: POLICY,
+    });
+    expect(result).toEqual({ converted: false });
+    expect(args.background).toBe(true);
+  });
+});

+ 70 - 0
src/hooks/task-session-manager/same-provider-policy.ts

@@ -0,0 +1,70 @@
+/**
+ * Opt-in same-provider background-to-foreground conversion.
+ *
+ * Some local inference backends expose multiple logical agent sessions but
+ * execute them on one shared model runtime (one accelerator, one KV-context
+ * pool). A foreground parent and a same-provider background child running
+ * concurrently on such a backend degrade throughput from repeated
+ * model/KV context switching between the two large sessions. When the user
+ * configures a provider with policy "foreground" in
+ * `backgroundJobs.sameProviderPolicy`, an explicit `background: true` task
+ * request whose parent and child both resolve to that provider is rewritten
+ * to the existing foreground execution path (no concurrency admission, no
+ * supervision, synchronous host execution).
+ *
+ * The decision is fail-open at every step: any unknown or undeterminable
+ * model or provider leaves `background: true` untouched.
+ */
+import { providerFromModel } from '../../utils/background-task-concurrency';
+
+export type SameProviderPolicy = 'foreground';
+
+export interface SameProviderConversionResult {
+  converted: boolean;
+  parentProvider?: string;
+  childProvider?: string;
+}
+
+export interface SameProviderConversionInput {
+  agentType: string;
+  parentSessionID: string;
+  args: { background?: unknown };
+  policy?: Record<string, SameProviderPolicy>;
+  getParentModel: (parentSessionID: string) => string | undefined;
+  getChildModel: (
+    agentType: string,
+    parentSessionID: string,
+  ) => string | undefined;
+}
+
+/**
+ * Converts a same-provider background task to foreground in place when the
+ * provider is opted in. Returns the decision so the caller can log it.
+ */
+export function convertSameProviderBackgroundTask(
+  input: SameProviderConversionInput,
+): SameProviderConversionResult {
+  if (input.args.background !== true) {
+    return { converted: false };
+  }
+
+  const parentModel = input.getParentModel(input.parentSessionID);
+  const childModel = input.getChildModel(
+    input.agentType,
+    input.parentSessionID,
+  );
+  const parentProvider = providerFromModel(parentModel);
+  const childProvider = providerFromModel(childModel);
+
+  if (
+    parentProvider === undefined ||
+    childProvider === undefined ||
+    parentProvider !== childProvider ||
+    input.policy?.[parentProvider] !== 'foreground'
+  ) {
+    return { converted: false };
+  }
+
+  input.args.background = false;
+  return { converted: true, parentProvider, childProvider };
+}

+ 28 - 1
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -25,6 +25,7 @@ import { log } from '../../utils/logger';
 import { SESSION_ID_PATTERN } from '../../utils/session';
 import { isMissingRememberedSessionError } from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
+import { convertSameProviderBackgroundTask } from './same-provider-policy';
 import { normalizeLateCancelledTaskOutput } from './status-utils';
 import { extractReadFiles } from './task-context-tracker';
 
@@ -65,6 +66,10 @@ export async function handleToolExecuteBefore(
       agentType: string,
       parentSessionID?: string,
     ) => string | undefined;
+    /** Current "provider/model" for a session (parent metadata store). */
+    getSessionModel?: (sessionID: string) => string | undefined;
+    /** Opt-in provider → "foreground" map for same-provider conversion. */
+    sameProviderPolicy?: Record<string, 'foreground'>;
     getLifecycleEpoch?: () => number;
   },
 ): Promise<void> {
@@ -96,7 +101,29 @@ export async function handleToolExecuteBefore(
   }
 
   const agentType = args.subagent_type.trim();
-  const background = args.background === true;
+  let background = args.background === true;
+  if (background) {
+    const conversion = convertSameProviderBackgroundTask({
+      agentType,
+      parentSessionID: input.sessionID,
+      args,
+      policy: deps.sameProviderPolicy,
+      getParentModel: (id) => deps.getSessionModel?.(id),
+      getChildModel: (agent, parent) => deps.getModelForAgent?.(agent, parent),
+    });
+    if (conversion.converted) {
+      background = false;
+      log(
+        '[task-session-manager] same-provider background task converted to foreground',
+        {
+          parentProvider: conversion.parentProvider,
+          childProvider: conversion.childProvider,
+          agentType,
+          parentSessionID: input.sessionID,
+        },
+      );
+    }
+  }
 
   const label = deriveTaskSessionLabel({
     description:

+ 2 - 0
src/index.ts

@@ -487,6 +487,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         (parentSessionID
           ? sessionMetadata.getModel(parentSessionID)
           : undefined),
+      sameProviderPolicy: runtime.backgroundJobs.sameProviderPolicy,
+      getSessionModel: (sessionID) => sessionMetadata.getModel(sessionID),
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {

+ 3 - 1
src/utils/background-task-concurrency.ts

@@ -340,7 +340,9 @@ function normalizeModel(model: string | undefined): string | undefined {
   return value || undefined;
 }
 
-function providerFromModel(model: string | undefined): string | undefined {
+export function providerFromModel(
+  model: string | undefined,
+): string | undefined {
   if (!model) return undefined;
   const slash = model.indexOf('/');
   return slash > 0 ? model.slice(0, slash) : undefined;