Files
claude-code/src/workflow/service.ts
claude-code-best 3edc370aa1 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>
2026-06-14 10:16:29 +08:00

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
listNamedWorkflows,
parseScript,
persistInlineScript,
resolveNamedWorkflow,
runWorkflow,
WORKFLOW_DIR_NAME,
type WorkflowHostContext,
type WorkflowInput,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getProjectRoot } from '../bootstrap/state.js'
import { logForDebugging } from '../utils/debug.js'
import { buildHostBundle, makeHostHandle } from './hostHandle.js'
import { installWorkflowNotifications } from './notifications.js'
import {
attachRunStatePersistence,
getRunsDir,
listPersistedRuns,
readRunState,
} from './persistence.js'
import { createProgressBus } from './progress/bus.js'
import {
createProgressStoreFromBus,
type ProgressStore,
type RunProgress,
} from './progress/store.js'
import { createWorkflowPorts } from './ports.js'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import type { ToolUseContext } from '../Tool.js'
/**
* WorkflowService工具U7与面板U9共享的唯一入口。
*
* - `ports`:共享的 WorkflowPorts工具描述符透传给引擎。
* - `launch`:解析脚本 → parseScript 快速校验 → taskRegistrar.register拿 runId+signal
* → detached runWorkflow → 结束后 complete/fail/kill。
* - `kill/listRuns/getRun/subscribe/listNamed`:面板与工具的辅助查询。
*/
export type WorkflowService = {
/** 共享端口(工具描述符用)。 */
ports: WorkflowPorts
/** 面板/工具启动 workflow解析脚本 → register → detached runWorkflow。 */
launch(
input: Pick<
WorkflowInput,
| 'script'
| 'name'
| 'scriptPath'
| 'args'
| 'description'
| 'resumeFromRunId'
| 'title'
| 'maxConcurrency'
>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): Promise<{ runId: string; scriptPath?: string }>
kill(runId: string): void
/**
* 进程退出 / 配置卸载时清理:杀掉所有 running run避免孤儿 task。
* 已完成/失败的 run 不受影响。幂等——多次调用安全。
*/
shutdown(): void
listRuns(): RunProgress[]
getRun(runId: string): RunProgress | undefined
/**
* 异步按 runId 查内存命中则返回miss 读盘 state.json不注入内存
* 供"按 runId 取历史 return"场景;面板展示请走 loadPersistedRuns + listRuns。
*/
getRunAsync(runId: string): Promise<RunProgress | undefined>
/**
* 扫盘把所有历史 run 的 state.json hydrate 进 store已存在 runId 跳过)。
* 进程单例内仅实际扫盘一次persistedLoaded flag重复调用立即返回。
*/
loadPersistedRuns(): Promise<void>
subscribe(listener: () => void): () => void
listNamed(workflowDir?: string): Promise<string[]>
}
let cached: WorkflowService | null = null
/** 进程单例。工具与面板共享同一 ports/registry/store。 */
export function getWorkflowService(): WorkflowService {
if (cached) return cached
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
const service = makeService(ports, store)
// 订阅 run_done 写终态快照到磁盘completed/failed/killed 三态共用入口shutdown-kill 也走此路径)。
// store 先于本订阅注册到 bus故 listener 执行时 store.get(runId) 已是终态。
attachRunStatePersistence(bus, store)
// 安装状态变更通知桥接commit 0768d4dc 承诺但旧实现落空的"完成时自动通知"
installWorkflowNotifications(service)
cached = service
return cached
}
/**
* 构造 service注入 ports + store
*
* 生产路径用 {@link getWorkflowService};测试用本函数直接注入 fake ports
* 避免触碰真实的 getProjectRoot/getCwd/analytics 等模块级副作用。
*
* @param cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。
* @param runsDirProvider 仅供测试注入 tmpdirBun ESM 模块命名空间只读,无法 monkey-patch getRunsDir
*/
export function makeService(
ports: WorkflowPorts,
store: ProgressStore,
cwdOverride?: string,
runsDirProvider: () => string = getRunsDir,
): WorkflowService {
const buildHost = (
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): WorkflowHostContext => ({
handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)),
// 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根;
// 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。
// cwdOverride 仅供测试注入临时目录(避免 inline 持久化写真实项目目录)。
cwd: cwdOverride ?? getProjectRoot(),
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
toolUseId: toolUseContext.toolUseId,
})
async function resolveSource(input: {
script?: string
name?: string
scriptPath?: string
}): Promise<{
script: string
workflowFile?: string
workflowName: string
}> {
if (input.script) {
return { script: input.script, workflowName: 'workflow' }
}
if (input.scriptPath) {
return {
script: await readFile(input.scriptPath, 'utf-8'),
workflowFile: input.scriptPath,
workflowName: 'workflow',
}
}
if (input.name) {
const dir = join(getProjectRoot(), WORKFLOW_DIR_NAME)
const found = await resolveNamedWorkflow(dir, input.name)
if (!found) {
throw new Error(
`命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/`,
)
}
return {
script: found.content,
workflowFile: found.path,
workflowName: input.name,
}
}
throw new Error('必须提供 script、name 或 scriptPath 之一')
}
// loadPersistedRuns 的进程单例 flag首次调用后置 true后续重复调用立即返回。
// 扫盘失败时复位允许下次重试。每个 makeService 调用独立闭包变量(测试构造新 service 时重置)。
let persistedLoaded = false
return {
ports,
async launch(input, toolUseContext, canUseTool) {
const { script, workflowFile, workflowName } = await resolveSource(input)
try {
parseScript(script)
} catch (e) {
throw new Error(`脚本校验失败:${(e as Error).message}`)
}
const host = buildHost(toolUseContext, canUseTool)
const { runId, signal } = ports.taskRegistrar.register(
{
workflowName,
...(workflowFile ? { workflowFile } : {}),
...(input.description ? { summary: input.description } : {}),
...(host.toolUseId ? { toolUseId: host.toolUseId } : {}),
...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}),
},
host.handle,
)
// inline 入口持久化脚本到 run 目录(与 WorkflowTool 对称),返回可复用路径。
// 写盘失败降级log不阻断 runscript 已在内存)。
let persistedScriptPath: string | undefined
if (!workflowFile && input.script) {
try {
persistedScriptPath = await persistInlineScript(
input.script,
runId,
host.cwd,
)
} catch (e) {
logForDebugging(
`workflow inline script persist failed: ${(e as Error).message}`,
)
}
}
// detached不 await让调用方立即拿到 runId结束路由到 registrar。
void runWorkflow({
script,
...(input.args !== undefined ? { args: input.args } : {}),
runId,
workflowName,
ports,
host: host.handle,
signal,
cwd: host.cwd,
budgetTotal: host.budgetTotal,
...(input.maxConcurrency !== undefined
? { maxConcurrency: input.maxConcurrency }
: {}),
...(input.resumeFromRunId ? { resume: true } : {}),
})
.then(result => {
if (result.status === 'completed') {
ports.taskRegistrar.complete(runId)
} else if (result.status === 'failed') {
ports.taskRegistrar.fail(runId, result.error ?? 'failed')
} else {
ports.taskRegistrar.kill(runId)
}
})
.catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
logForDebugging(`workflow launched: ${runId} (${workflowName})`)
return {
runId,
...(persistedScriptPath ? { scriptPath: persistedScriptPath } : {}),
}
},
kill(runId) {
ports.taskRegistrar.kill(runId)
},
shutdown() {
// 仅杀 running已完成/失败的 run taskRegistrar 已回收 bindingkill 是 no-op。
// taskRegistrar.kill 对未知 runId 安全 no-op因此幂等——多次 shutdown 不重复抛错。
// 每个 kill 单独 try/catchkill 内部走 setAppState进程 exit 阶段触发 React 重渲染
// 可能抛错render 已卸载等);单个失败不应阻断其他 run 的清理。
for (const run of store.list()) {
if (run.status !== 'running') continue
try {
ports.taskRegistrar.kill(run.runId)
} catch (e) {
logForDebugging(
`workflow shutdown: kill ${run.runId} failed: ${(e as Error).message}`,
)
}
}
},
listRuns: () => store.list(),
getRun: id => store.get(id),
async getRunAsync(id) {
const mem = store.get(id)
if (mem) return mem
return (await readRunState(runsDirProvider(), id)) ?? undefined
},
async loadPersistedRuns() {
if (persistedLoaded) return
persistedLoaded = true
try {
const runs = await listPersistedRuns(runsDirProvider())
for (const run of runs) store.hydrate(run)
} catch (e) {
// 扫盘失败不阻断面板log + 复位 flag 允许下次重试
logForDebugging(
`[workflow warn] loadPersistedRuns failed: ${(e as Error).message}`,
)
persistedLoaded = false
}
},
subscribe: fn => store.subscribe(fn),
async listNamed(workflowDir) {
return listNamedWorkflows(
workflowDir ?? join(getProjectRoot(), WORKFLOW_DIR_NAME),
)
},
}
}
/** 测试用:重置单例(避免跨用例污染)。 */
export function __resetWorkflowServiceForTests(): void {
cached = null
}
/**
* 返回已实例化的 service不创建。进程退出 / 配置卸载时用本函数 peek
* 没用过 workflow 则 cached 仍为 null——避免在 exit hook 里副作用地创建 bus/ports。
*/
export function peekWorkflowService(): WorkflowService | null {
return cached
}