mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat(workflow): 默认并发降为 3 并支持 per-run maxConcurrency 注入
- DEFAULT_MAX_CONCURRENCY=3 替代旧的 min(16, cores-2);MAX_CONCURRENCY_CAP=16 保留为用户输入的绝对上限 - 新增 clampMaxConcurrency() 处理 undefined/<1/>CAP 边界 - WorkflowInput schema 新增 maxConcurrency: number.int().min(1).max(16).optional() - 引擎层 context/runWorkflow 全链路透传:semaphore 容量来自 per-run 入参 - WorkflowTool prompt 增加指引:fan-out 场景先用 AskUserQuestion 与用户确认并发再启动 - 同步 ultracode skill + audit workflow spec 的并发文字(删 cpu-cores 公式) - 同步 docs/features/workflow-scripts.md 旧公式 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -242,6 +242,15 @@ test('元数据方法:description/prompt/renderToolUseMessage', async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('prompt 包含默认并发 3 + AskUserQuestion 指引', async () => {
|
||||
const { ports } = mockPorts('/tmp', new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const p = await tool.prompt()
|
||||
expect(p).toMatch(/default is 3/i)
|
||||
expect(p).toMatch(/maxConcurrency/i)
|
||||
expect(p).toMatch(/AskUserQuestion/i)
|
||||
})
|
||||
|
||||
test('name 不存在 → 返回错误(不进后台)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Semaphore, maxConcurrency } from '../engine/concurrency.js'
|
||||
import {
|
||||
clampMaxConcurrency,
|
||||
Semaphore,
|
||||
maxConcurrency,
|
||||
} from '../engine/concurrency.js'
|
||||
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
|
||||
|
||||
test('Semaphore 限制并发,permit 转移不泄漏', async () => {
|
||||
const sem = new Semaphore(2)
|
||||
@@ -19,10 +24,24 @@ test('Semaphore 限制并发,permit 转移不泄漏', async () => {
|
||||
expect(peak).toBe(2) // 永不超过 permits
|
||||
})
|
||||
|
||||
test('maxConcurrency 落在 [1, 16]', () => {
|
||||
const n = maxConcurrency()
|
||||
expect(n).toBeGreaterThanOrEqual(1)
|
||||
expect(n).toBeLessThanOrEqual(16)
|
||||
test('maxConcurrency 返回 DEFAULT_MAX_CONCURRENCY (=3)', () => {
|
||||
expect(maxConcurrency()).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(maxConcurrency()).toBe(3)
|
||||
})
|
||||
|
||||
test('clampMaxConcurrency:undefined/NaN→DEFAULT;<1→1;>CAP→CAP;正常原值', () => {
|
||||
expect(clampMaxConcurrency(undefined)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(Number.NaN)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(0)).toBe(1)
|
||||
expect(clampMaxConcurrency(-3)).toBe(1)
|
||||
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP + 100)).toBe(
|
||||
MAX_CONCURRENCY_CAP,
|
||||
)
|
||||
expect(clampMaxConcurrency(5)).toBe(5)
|
||||
expect(clampMaxConcurrency(1)).toBe(1)
|
||||
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP)).toBe(MAX_CONCURRENCY_CAP)
|
||||
// 小数截断(Semaphore 已有 Math.max(1, Math.floor);clampMaxConcurrency 显式 trunc)
|
||||
expect(clampMaxConcurrency(2.9)).toBe(2)
|
||||
})
|
||||
|
||||
test('Semaphore(0) 至少 1 permit,acquire 不阻塞', async () => {
|
||||
|
||||
@@ -40,6 +40,69 @@ test('createSharedResources 初始化预算与计数', () => {
|
||||
expect(r.depth).toBe(0)
|
||||
})
|
||||
|
||||
test('createSharedResources:maxConcurrency 控制 semaphore permits', async () => {
|
||||
// 默认 permits = DEFAULT_MAX_CONCURRENCY = 3:4 次 acquire 后第 4 次 pending
|
||||
const r1 = createSharedResources(null)
|
||||
const releases1: Array<() => void> = []
|
||||
for (let i = 0; i < 3; i++) releases1.push(await r1.semaphore.acquire())
|
||||
let fourthResolved = false
|
||||
const pending = r1.semaphore.acquire().then(r => {
|
||||
fourthResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(fourthResolved).toBe(false)
|
||||
releases1[0]!() // 释放一个,第四个应被唤醒
|
||||
releases1.push(await pending)
|
||||
for (const rel of releases1) rel()
|
||||
|
||||
// 显式 maxConcurrency=2:第 3 次 acquire pending
|
||||
const r2 = createSharedResources(null, 2)
|
||||
const releases2: Array<() => void> = []
|
||||
releases2.push(await r2.semaphore.acquire())
|
||||
releases2.push(await r2.semaphore.acquire())
|
||||
let thirdResolved = false
|
||||
const pending2 = r2.semaphore.acquire().then(r => {
|
||||
thirdResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(thirdResolved).toBe(false)
|
||||
releases2[0]!()
|
||||
releases2.push(await pending2)
|
||||
for (const rel of releases2) rel()
|
||||
})
|
||||
|
||||
test('createEngineContext 透传 maxConcurrency 到 resources.semaphore', async () => {
|
||||
const ctx = createEngineContext({
|
||||
ports: mockPorts(),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r-mc',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
maxConcurrency: 1,
|
||||
})
|
||||
// maxConcurrency=1:第二次 acquire 应 pending
|
||||
const first = await ctx.resources.semaphore.acquire()
|
||||
let secondResolved = false
|
||||
const pending = ctx.resources.semaphore.acquire().then(r => {
|
||||
secondResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(secondResolved).toBe(false)
|
||||
first()
|
||||
await pending
|
||||
})
|
||||
|
||||
test('createEngineContext 复制 journal 并重置游标', () => {
|
||||
const journal = [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ test('持久化 / 结构化 / 命名 workflow / 进度 API 完整导出', () =>
|
||||
test('并发 / 预算 / 错误类完整导出', () => {
|
||||
expect(typeof wf.Semaphore).toBe('function')
|
||||
expect(typeof wf.maxConcurrency).toBe('function')
|
||||
expect(typeof wf.clampMaxConcurrency).toBe('function')
|
||||
expect(typeof wf.Budget).toBe('function')
|
||||
expect(typeof wf.BudgetExhaustedError).toBe('function')
|
||||
expect(typeof wf.WorkflowError).toBe('function')
|
||||
@@ -49,7 +50,7 @@ test('引擎常量值稳定', () => {
|
||||
expect(wf.MAX_TOTAL_AGENTS).toBe(1000)
|
||||
expect(wf.MAX_ITEMS_PER_CALL).toBe(4096)
|
||||
expect(wf.MAX_CONCURRENCY_CAP).toBe(16)
|
||||
expect(wf.MAX_CONCURRENCY_OFFSET).toBe(2)
|
||||
expect(wf.DEFAULT_MAX_CONCURRENCY).toBe(3)
|
||||
expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs'])
|
||||
})
|
||||
|
||||
|
||||
@@ -380,6 +380,57 @@ test('budgetTotal 耗尽 → failed', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('maxConcurrency 透传:并行 agent 受 run 级并发槽位限制', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 8)
|
||||
})
|
||||
active--
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
},
|
||||
progressEmitter: { emit: () => {} },
|
||||
taskRegistrar: {
|
||||
register: () => ({ runId: 'r', signal: new AbortController().signal }),
|
||||
complete: () => {},
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(dir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
const result = await runWorkflow({
|
||||
script: `return parallel(Array.from({length: 8}, () => () => agent('p')))`,
|
||||
runId: 'run-mc',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
maxConcurrency: 2,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() 引用语法错的子脚本 → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ test('全部已知字段可填', () => {
|
||||
resumeFromRunId: 'run-1',
|
||||
description: 'do thing',
|
||||
title: 'T',
|
||||
maxConcurrency: 3,
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
@@ -42,3 +43,20 @@ test('未知字段被 strip(zod 默认非 strict,safeParse 成功)', () =>
|
||||
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('maxConcurrency:1–16 整数合法;0/17/小数/非数字被拒', () => {
|
||||
for (const n of [1, 3, 5, 16]) {
|
||||
expect(workflowInputSchema.safeParse({ maxConcurrency: n }).success).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
for (const bad of [0, -1, 17, 100, 1.5, '3', NaN]) {
|
||||
expect(workflowInputSchema.safeParse({ maxConcurrency: bad }).success).toBe(
|
||||
false,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('maxConcurrency optional(省略时 safeParse 成功)', () => {
|
||||
expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user