首页
/ oh-my-opencode 后台代理全局并发上限设计:maxBackgroundAgents 配置从 Zod Schema 到 ConcurrencyManager 落地的完整方案

oh-my-opencode 后台代理全局并发上限设计:maxBackgroundAgents 配置从 Zod Schema 到 ConcurrencyManager 落地的完整方案

2026-09-04 21:08:48作者:申梦珏Efrain

本文基于仓库中的变更规格文档 code-changes.md,完整讲解如何为 oh-my-opencode 的 background_task 配置新增全局并发上限 maxBackgroundAgents:从 Zod Schema 字段定义、校验测试、ConcurrencyManager 全局计数器,到 BackgroundManager 在任务启动、跟踪、完成、取消、报错全链路上的槽位获取与释放。读完本文,你能掌握该项目的后台代理并发控制机制(按模型/Provider 分层的 lane 限制 + 队列交接模式),并理解一个"全局资源上限"类配置项从声明到运行时强制的完整设计路径。

背景:现有的按模型并发限制缺少全局兜底

要理解这次变更的动机,先看当前仓库中已经存在的并发控制实现。oh-my-opencode 的后台代理系统(packages/omo-opencode 包下的 background-agent 特性)通过 ConcurrencyManager 限制每个模型/Provider 同时运行的任务数。当前实现见 concurrency.ts

getConcurrencyLimit(model: string): number {
  const modelLimit = this.config?.modelConcurrency?.[model]
  if (modelLimit !== undefined) {
    return modelLimit === 0 ? Infinity : modelLimit
  }
  const provider = model.split('/')[0]
  const providerLimit = this.config?.providerConcurrency?.[provider]
  if (providerLimit !== undefined) {
    return providerLimit === 0 ? Infinity : providerLimit
  }
  const defaultLimit = this.config?.defaultConcurrency
  if (defaultLimit !== undefined) {
    return defaultLimit === 0 ? Infinity : defaultLimit
  }
  return 5
}

从源码结构看,这是一套三级回退的 lane 机制:modelConcurrency(精确到 provider/model 全名)→ providerConcurrency(按 provider/model 前缀切出的 provider 名)→ defaultConcurrency(全局默认)→ 硬编码兜底值 5。配置中把某一级设为 0 表示"不限"(返回 Infinityconcurrency.ts)。

acquire() 的语义是"限流即排队":当某个 key 的计数达到上限时,请求不会失败,而是被压入 Map<key, QueueEntry[]> 等待队列(concurrency.ts)。release() 释放槽位时优先把空位交接给队列中的等待者(计数不变),没有等待者才真正递减计数(concurrency.ts)。队列项使用 settled 标志防止"已被 release 解决"的条目再被 cancelWaiters() 重复拒绝,这是典型的 double-resolution 防护模式(concurrency.ts)。

这套机制的盲区正是本次变更要解决的问题:所有限制都是按 lane 的。一个用户在 anthropic/claude-opus-4-6openai/gpt-5google/gemini 三个 lane 各开 5 个任务时,每个 lane 都没超限,但整机上已经跑了 15 个后台代理——系统资源可能已经耗尽。官方文档 omo-json.md 中关于 senpi 侧 global_concurrency 的描述也印证了这一点:"OpenCode background_task is unaffected (parity is a follow-up)"——即 OpenCode 侧的全局上限尚属待补齐项。maxBackgroundAgents 就是补齐这块的"单一旋钮":无论任务跑在哪个模型上,同时运行的后台代理总数不得超过该值

变更一:Zod Schema 新增 maxBackgroundAgents 字段

规格文档针对的源文件路径写作 src/config/schema/background-task.ts,对应当前仓库中的 background-task.ts。规格中给出的完整 schema 快照如下(包含新增的 maxBackgroundAgents 字段及其 JSDoc 注释):

import { z } from "zod"

export const BackgroundTaskConfigSchema = z.object({
  defaultConcurrency: z.number().min(1).optional(),
  providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
  modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
  maxDepth: z.number().int().min(1).optional(),
  maxDescendants: z.number().int().min(1).optional(),
  /** Maximum number of background agents that can run simultaneously across all models/providers (default: 5, minimum: 1) */
  maxBackgroundAgents: z.number().int().int().min(1).optional(),
  /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
  staleTimeoutMs: z.number().min(60000).optional(),
  /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
  messageStalenessTimeoutMs: z.number().min(60000).optional(),
  syncPollTimeoutMs: z.number().min(60000).optional(),
})

export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

说明:上述是规格文档中的 schema 快照,用于展示新增字段的写法;当前仓库 background-task.ts 实际还包含 taskTtlMssessionGoneTimeoutMsmaxToolCallscircuitBreaker 等后续演进出来的字段,且并发子字段名(如 maxDescendants 对应当前的 maxLiveDescendantsPerRoot)已调整。本文以规格文档为准讲解 maxBackgroundAgents 的引入方式。

三个设计要点:

  1. z.number().int().min(1).optional() —— 完全复用 maxDepthmaxDescendants 的既有模式:整数、下限 1、可选。可选意味着配置未提供时不报错,运行时默认值 5 由 ConcurrencyManager 兜底(见下文 getMaxBackgroundAgents())。
  2. JSDoc 注释即文档 —— 注释写明 default: 5, minimum: 1,配置语义不依赖外部文档即可自解释。
  3. 无需改动 barrel 导出 —— 规格文档指出 src/config/schema.ts 已经 export * from "./schema/background-task",类型由 z.infer 自动推导,加字段零侵入。

变更二:Schema 校验测试

规格要求在 background-task.test.ts 中已有的 syncPollTimeoutMs describe 块之后追加 maxBackgroundAgents 测试块,覆盖"合法值、下边界、低于下限、未提供、非整数"五类场景:

  describe("maxBackgroundAgents", () => {
    describe("#given valid maxBackgroundAgents (10)", () => {
      test("#when parsed #then returns correct value", () => {
        const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 10 })

        expect(result.maxBackgroundAgents).toBe(10)
      })
    })

    describe("#given maxBackgroundAgents of 1 (minimum)", () => {
      test("#when parsed #then returns correct value", () => {
        const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 1 })

        expect(result.maxBackgroundAgents).toBe(1)
      })
    })

    describe("#given maxBackgroundAgents below minimum (0)", () => {
      test("#when parsed #then throws ZodError", () => {
        let thrownError: unknown

        try {
          BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 0 })
        } catch (error) {
          thrownError = error
        }

        expect(thrownError).toBeInstanceOf(ZodError)
      })
    })

    describe("#given maxBackgroundAgents not provided", () => {
      test("#when parsed #then field is undefined", () => {
        const result = BackgroundTaskConfigSchema.parse({})

        expect(result.maxBackgroundAgents).toBeUndefined()
      })
    })

    describe('#given maxBackgroundAgents is non-integer (2.5)', () => {
      test("#when parsed #then throws ZodError", () => {
        let thrownError: unknown

        try {
          BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 2.5 })
        } catch (error) {
          thrownError = error
        }

        expect(thrownError).toBeInstanceOf(ZodError)
      })
    })
  })

测试命名沿用该文件既有的 #given / #when / #then 嵌套 describe 风格(与 maxDepthmaxDescendantssyncPollTimeoutMs 的测试完全一致)。其中两个细节值得注意:

  • 0 被判为非法:与并发子字段不同(0 表示"不限"),全局上限 0 没有合理语义——"永远不许运行后台代理"的场景应由用户干脆不启动代理来表达,因此 min(1) 直接拒绝;
  • 2.5 触发 ZodError.int() 约束保证上限是整数,避免"1.5 个代理"这类歧义状态。

变更三:ConcurrencyManager 增加全局计数器

核心实现改动集中在 concurrency.ts。规格文档给出的目标形态如下(... 处为未改动的既有方法,当前仓库中已存在,见 concurrency.ts):

import type { BackgroundTaskConfig } from "../../config/schema"

const DEFAULT_MAX_BACKGROUND_AGENTS = 5

/**
 * Queue entry with settled-flag pattern to prevent double-resolution.
 *
 * The settled flag ensures that cancelWaiters() doesn't reject
 * an entry that was already resolved by release().
 */
interface QueueEntry {
  resolve: () => void
  rawReject: (error: Error) => void
  settled: boolean
}

export class ConcurrencyManager {
  private config?: BackgroundTaskConfig
  private counts: Map<string, number> = new Map()
  private queues: Map<string, QueueEntry[]> = new Map()
  private globalRunningCount = 0

  constructor(config?: BackgroundTaskConfig) {
    this.config = config
  }

  getMaxBackgroundAgents(): number {
    return this.config?.maxBackgroundAgents ?? DEFAULT_MAX_BACKGROUND_AGENTS
  }

  getGlobalRunningCount(): number {
    return this.globalRunningCount
  }

  canSpawnGlobally(): boolean {
    return this.globalRunningCount < this.getMaxBackgroundAgents()
  }

  acquireGlobal(): void {
    this.globalRunningCount++
  }

  releaseGlobal(): void {
    if (this.globalRunningCount > 0) {
      this.globalRunningCount--
    }
  }

  getConcurrencyLimit(model: string): number {
    // ... existing implementation unchanged ...
  }

  async acquire(model: string): Promise<void> {
    // ... existing implementation unchanged ...
  }

  release(model: string): void {
    // ... existing implementation unchanged ...
  }

  cancelWaiters(model: string): void {
    // ... existing implementation unchanged ...
  }

  clear(): void {
    for (const [model] of this.queues) {
      this.cancelWaiters(model)
    }
    this.counts.clear()
    this.queues.clear()
    this.globalRunningCount = 0
  }

  getCount(model: string): number {
    return this.counts.get(model) ?? 0
  }

  getQueueLength(model: string): number {
    return this.queues.get(model)?.length ?? 0
  }
}

关键变化逐条拆解:

  • DEFAULT_MAX_BACKGROUND_AGENTS = 5 常量:未配置时的全局上限,与 getConcurrencyLimit() 的硬编码兜底 5 保持同一量级,行为可预期;
  • globalRunningCount 私有字段:一个跨所有 lane 的单一计数器,与 per-model 的 counts: Map 正交——两者可以同时生效,实际并发受"全局上限"和"所在 lane 上限"中更紧的一方约束;
  • canSpawnGlobally() / acquireGlobal() / releaseGlobal():全局槽位的查/取/还三件套。与 per-model 的 acquire()(限流即排队)不同,全局槽位是立即失败语义——launch() 里发现 canSpawnGlobally() 为 false 时直接抛错(见下文),不做排队等待。这个设计差异是合理的:per-model 排队是为了让同 lane 任务公平轮转,而全局资源耗尽时新任务再等下去只会堆积,明确报错让用户"等已有任务完成或调大配置"才是更快的反馈;
  • releaseGlobal()> 0 保护:多调一次 release 不会让计数变成负数,容忍竞态下的重复释放;
  • clear() 重置全局计数:manager 清理/关闭时,全局计数与 per-model 状态一起归零,防止"幽灵槽位"永久占用全局上限。

变更四:全局上限单元测试

规格要求在 concurrency.test.ts 中追加独立的 describe 块,用 given/when/then 注释风格覆盖默认值、配置值、上下限行为与重置:

describe("ConcurrencyManager global background agent limit", () => {
  test("should default max background agents to 5 when no config", () => {
    // given
    const manager = new ConcurrencyManager()

    // when
    const max = manager.getMaxBackgroundAgents()

    // then
    expect(max).toBe(5)
  })

  test("should use configured maxBackgroundAgents", () => {
    // given
    const config: BackgroundTaskConfig = { maxBackgroundAgents: 10 }
    const manager = new ConcurrencyManager(config)

    // when
    const max = manager.getMaxBackgroundAgents()

    // then
    expect(max).toBe(10)
  })

  test("should allow spawning when under global limit", () => {
    // given
    const config: BackgroundTaskConfig = { maxBackgroundAgents: 2 }
    const manager = new ConcurrencyManager(config)

    // when
    manager.acquireGlobal()

    // then
    expect(manager.canSpawnGlobally()).toBe(true)
    expect(manager.getGlobalRunningCount()).toBe(1)
  })

  test("should block spawning when at global limit", () => {
    // given
    const config: BackgroundTaskConfig = { maxBackgroundAgents: 2 }
    const manager = new ConcurrencyManager(config)

    // when
    manager.acquireGlobal()
    manager.acquireGlobal()

    // then
    expect(manager.canSpawnGlobally()).toBe(false)
    expect(manager.getGlobalRunningCount()).toBe(2)
  })

  test("should allow spawning again after release", () => {
    // given
    const config: BackgroundTaskConfig = { maxBackgroundAgents: 1 }
    const manager = new ConcurrencyManager(config)
    manager.acquireGlobal()

    // when
    manager.releaseGlobal()

    // then
    expect(manager.canSpawnGlobally()).toBe(true)
    expect(manager.getGlobalRunningCount()).toBe(0)
  })

  test("should not go below zero on extra release", () => {
    // given
    const manager = new ConcurrencyManager()

    // when
    manager.releaseGlobal()

    // then
    expect(manager.getGlobalRunningCount()).toBe(0)
  })

  test("should reset global count on clear", () => {
    // given
    const config: BackgroundTaskConfig = { maxBackgroundAgents: 5 }
    const manager = new ConcurrencyManager(config)
    manager.acquireGlobal()
    manager.acquireGlobal()
    manager.acquireGlobal()

    // when
    manager.clear()

    // then
    expect(manager.getGlobalRunningCount()).toBe(0)
  })
})

七个用例合起来恰好锁死了全局计数的全部状态转换:默认 5 → 配置 10 → 未达上限可 spawn → 达到上限被阻塞 → 释放后可再 spawn → 多余 release 不越界 → clear() 归零。

变更五:BackgroundManager 全链路上的强制与释放

计数器本身不会自动生效,真正的难点在 manager.ts 中把所有"任务生命周期事件"与全局槽位的获取/释放正确配对。规格文档覆盖了两处入口检查和四处释放点

launch():启动前做全局检查,创建后取槽

  async launch(input: LaunchInput): Promise<BackgroundTask> {
    // ... existing logging ...

    if (!input.agent || input.agent.trim() === "") {
      throw new Error("Agent parameter is required")
    }

    // Check global background agent limit before spawn guard
    if (!this.concurrencyManager.canSpawnGlobally()) {
      const max = this.concurrencyManager.getMaxBackgroundAgents()
      const current = this.concurrencyManager.getGlobalRunningCount()
      throw new Error(
        `Background agent spawn blocked: ${current} agents running, max is ${max}. Wait for existing tasks to complete or increase background_task.maxBackgroundAgents.`
      )
    }

    const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)

    try {
      // ... existing code ...

      // After task creation, before queueing:
      this.concurrencyManager.acquireGlobal()

      // ... rest of existing code ...
    } catch (error) {
      spawnReservation.rollback()
      throw error
    }
  }

三个设计细节:

  1. 报错信息自带运维指引:错误消息包含当前运行数、上限值,并直接提示"等待现有任务完成或增大 background_task.maxBackgroundAgents",用户无需查文档即可自救;
  2. 检查放在 reserveSubagentSpawn 之前:全局上限被拒时不产生任何 spawn 预留,避免无谓占用父会话的子代理名额(仓库中该预留机制见 subagent-spawn-limits.ts);
  3. acquireGlobal() 在任务创建成功后、入队前调用,且 catch 分支回滚 spawn 预留——确保"创建失败的任务不占全局槽位"。

trackTask():外部任务注册同样受全局上限约束

  async trackTask(input: { ... }): Promise<BackgroundTask> {
    const existingTask = this.tasks.get(input.taskId)
    if (existingTask) {
      // ... existing re-registration logic unchanged ...
      return existingTask
    }

    // Check global limit for new external tasks
    if (!this.concurrencyManager.canSpawnGlobally()) {
      const max = this.concurrencyManager.getMaxBackgroundAgents()
      const current = this.concurrencyManager.getGlobalRunningCount()
      throw new Error(
        `Background agent spawn blocked: ${current} agents running, max is ${max}. Wait for existing tasks to complete or increase background_task.maxBackgroundAgents.`
      )
    }

    // ... existing task creation ...
    this.concurrencyManager.acquireGlobal()

    // ... rest unchanged ...
  }

trackTask() 是外部来源任务(非 launch() 直接创建)的登记入口。已存在的 taskId 走幂等的重注册逻辑直接返回,不重复取槽;新任务则与 launch() 完全一致地受全局上限约束。两个入口共用同一检查逻辑,保证了不管任务从哪条路径进入管理器,全局计数口径一致。

tryCompleteTask():完成路径释放槽位

  private async tryCompleteTask(task: BackgroundTask, source: string): Promise<boolean> {
    if (task.status !== "running") {
      // ... existing guard ...
      return false
    }

    task.status = "completed"
    task.completedAt = new Date()
    // ... existing history record ...

    removeTaskToastTracking(task.id)

    // Release per-model concurrency
    if (task.concurrencyKey) {
      this.concurrencyManager.release(task.concurrencyKey)
      task.concurrencyKey = undefined
    }

    // Release global slot
    this.concurrencyManager.releaseGlobal()

    // ... rest unchanged ...
  }

注意释放的顺序与配对:先释放 per-model 的 concurrencyKey 槽位(释放即可能把 lane 空位交接给等待者),再释放全局槽位;task.concurrencyKeyundefined 防止后续路径重复释放同一 lane 槽位。

cancelTask():取消路径释放槽位(pending 任务除外)

  async cancelTask(taskId: string, options?: { ... }): Promise<boolean> {
    // ... existing code up to concurrency release ...

    if (task.concurrencyKey) {
      this.concurrencyManager.release(task.concurrencyKey)
      task.concurrencyKey = undefined
    }

    // Release global slot (only for running tasks, pending never acquired)
    if (task.status !== "pending") {
      this.concurrencyManager.releaseGlobal()
    }

    // ... rest unchanged ...
  }

这里有一个关键的不对称处理:pending 状态的任务不释放全局槽位,因为尚未进入 per-model 队列、真正开始运行的任务从未执行过 acquireGlobal()——对 pending 任务调用释放会造成计数漂移。这个"只有 running 任务才持有全局槽位"的约定是整条释放链正确性的基石。

session.error 与 prompt 错误路径:异常也要还槽

任务因会话级错误终止时:

    if (event.type === "session.error") {
      // ... existing error handling ...

      task.status = "error"
      // ...

      if (task.concurrencyKey) {
        this.concurrencyManager.release(task.concurrencyKey)
        task.concurrencyKey = undefined
      }

      // Release global slot
      this.concurrencyManager.releaseGlobal()

      // ... rest unchanged ...
    }

startTask() 内部 prompt 请求失败(含模型建议重试)的 catch 分支同样处理:

    promptWithModelSuggestionRetry(this.client, { ... }).catch((error) => {
      // ... existing error handling ...
      if (existingTask) {
        existingTask.status = "interrupt"
        // ...
        if (existingTask.concurrencyKey) {
          this.concurrencyManager.release(existingTask.concurrencyKey)
          existingTask.concurrencyKey = undefined
        }

        // Release global slot
        this.concurrencyManager.releaseGlobal()

        // ... rest unchanged ...
      }
    })

如果漏掉这两个异常路径,一次 provider 报错就会让全局槽位永久"泄漏",跑满上限后所有新任务都会被 Background agent spawn blocked 拒绝且永不恢复——这正是规格文档在配套 PR 描述中强调的"Release global slots on task completion, cancellation, error, and interrupt to prevent slot leaks"(见 pr-description.md)。

变更汇总与验证方式

规格文档给出的最终统计:

文件 新增行数 修改行数
src/config/schema/background-task.ts 2 0
src/config/schema/background-task.test.ts ~50 0
src/features/background-agent/concurrency.ts ~25 1(clear()
src/features/background-agent/concurrency.test.ts ~70 0
src/features/background-agent/manager.ts ~20 0

总计约 167 行新增、1 行修改,横跨 5 个文件(路径对应仓库中 packages/omo-opencode/ 前缀下的同名文件)。验证命令按配套 PR 描述执行:

bun test src/config/schema/background-task.test.ts   # schema 校验
bun test src/features/background-agent/concurrency.test.ts  # 全局上限
bun run typecheck
bun run build

用户侧的配置用法(对应 .opencode/oh-my-opencode.jsonc):

{
  "background_task": {
    "maxBackgroundAgents": 10  // default: 5, min: 1
  }
}

小结:一个"全局上限"配置项的正确姿势

这套变更示范了在 oh-my-opencode 这类插件架构里新增资源类配置的完整方法论:

  1. Schema 层z.number().int().min(1).optional() 可选字段 + 明确下限,默认值不在 schema 硬编码,留给运行时;
  2. 运行时层:在 ConcurrencyManager 中引入与 per-model 计数正交的 globalRunningCount,提供"查/取/还"三接口,且 release 带越界保护、clear() 随 manager 生命周期归零;
  3. 强制层:入口(launch() / trackTask())统一检查并立即失败(报错信息含自救指引),出口(完成、取消、会话错误、prompt 失败四条释放路径)与 acquireGlobal() 严格配对,且明确"pending 任务不持有全局槽位"的不变量;
  4. 测试层:schema 五类边界用例 + 计数器七个状态转换用例,两层测试独立可回归。

需要说明的是:规格文档位于 .agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/ 目录,属于 PR 工作流工作区中产出的变更设计;当前仓库主干代码(background-task.tsconcurrency.ts)尚未包含 maxBackgroundAgents 字段,本文将其作为该功能的设计规格来解读,实际合入状态请以仓库当前代码为准。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
docsdocs
暂无描述
Markdown
889
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341