oh-my-opencode 后台代理全局并发上限设计:maxBackgroundAgents 配置从 Zod Schema 到 ConcurrencyManager 落地的完整方案
本文基于仓库中的变更规格文档 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 表示"不限"(返回 Infinity,concurrency.ts)。
acquire() 的语义是"限流即排队":当某个 key 的计数达到上限时,请求不会失败,而是被压入 Map<key, QueueEntry[]> 等待队列(concurrency.ts)。release() 释放槽位时优先把空位交接给队列中的等待者(计数不变),没有等待者才真正递减计数(concurrency.ts)。队列项使用 settled 标志防止"已被 release 解决"的条目再被 cancelWaiters() 重复拒绝,这是典型的 double-resolution 防护模式(concurrency.ts)。
这套机制的盲区正是本次变更要解决的问题:所有限制都是按 lane 的。一个用户在 anthropic/claude-opus-4-6、openai/gpt-5、google/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 实际还包含
taskTtlMs、sessionGoneTimeoutMs、maxToolCalls、circuitBreaker等后续演进出来的字段,且并发子字段名(如maxDescendants对应当前的maxLiveDescendantsPerRoot)已调整。本文以规格文档为准讲解maxBackgroundAgents的引入方式。
三个设计要点:
z.number().int().min(1).optional()—— 完全复用maxDepth、maxDescendants的既有模式:整数、下限 1、可选。可选意味着配置未提供时不报错,运行时默认值 5 由ConcurrencyManager兜底(见下文getMaxBackgroundAgents())。- JSDoc 注释即文档 —— 注释写明 default: 5, minimum: 1,配置语义不依赖外部文档即可自解释。
- 无需改动 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 风格(与 maxDepth、maxDescendants、syncPollTimeoutMs 的测试完全一致)。其中两个细节值得注意:
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
}
}
三个设计细节:
- 报错信息自带运维指引:错误消息包含当前运行数、上限值,并直接提示"等待现有任务完成或增大
background_task.maxBackgroundAgents",用户无需查文档即可自救; - 检查放在
reserveSubagentSpawn之前:全局上限被拒时不产生任何 spawn 预留,避免无谓占用父会话的子代理名额(仓库中该预留机制见 subagent-spawn-limits.ts); 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.concurrencyKey 置 undefined 防止后续路径重复释放同一 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 这类插件架构里新增资源类配置的完整方法论:
- Schema 层:
z.number().int().min(1).optional()可选字段 + 明确下限,默认值不在 schema 硬编码,留给运行时; - 运行时层:在
ConcurrencyManager中引入与 per-model 计数正交的globalRunningCount,提供"查/取/还"三接口,且release带越界保护、clear()随 manager 生命周期归零; - 强制层:入口(
launch()/trackTask())统一检查并立即失败(报错信息含自救指引),出口(完成、取消、会话错误、prompt 失败四条释放路径)与acquireGlobal()严格配对,且明确"pending 任务不持有全局槽位"的不变量; - 测试层:schema 五类边界用例 + 计数器七个状态转换用例,两层测试独立可回归。
需要说明的是:规格文档位于 .agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/ 目录,属于 PR 工作流工作区中产出的变更设计;当前仓库主干代码(background-task.ts、concurrency.ts)尚未包含 maxBackgroundAgents 字段,本文将其作为该功能的设计规格来解读,实际合入状态请以仓库当前代码为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00