mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)
- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch) - AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill) - ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断 - 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触 - 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -33,6 +33,7 @@ const CTX = {
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r',
|
||||
agentId: 1,
|
||||
}
|
||||
|
||||
test('resolve 默认走 default adapter,run 返回结果', async () => {
|
||||
|
||||
@@ -24,6 +24,14 @@ type CtxOverrides = Partial<{
|
||||
truncated: string[]
|
||||
agentAdapterRegistry: AgentAdapterRegistry
|
||||
loggerWarn: (msg: string) => void
|
||||
// taskRegistrar 的 agent 级 abort 绑定(agent kill 桥接)。
|
||||
// 提供后 buildCtx 注入到 ports.taskRegistrar;hooks.agent 把闭包塞进 adapterCtx。
|
||||
registerAgentAbort: (
|
||||
runId: string,
|
||||
agentId: number,
|
||||
ac: AbortController,
|
||||
) => void
|
||||
unregisterAgentAbort: (runId: string, agentId: number) => void
|
||||
}>
|
||||
|
||||
function buildCtx(overrides: CtxOverrides = {}): {
|
||||
@@ -50,6 +58,12 @@ function buildCtx(overrides: CtxOverrides = {}): {
|
||||
fail: () => {},
|
||||
kill: () => {},
|
||||
pendingAction: () => overrides.pending ?? null,
|
||||
...(overrides.registerAgentAbort
|
||||
? { registerAgentAbort: overrides.registerAgentAbort }
|
||||
: {}),
|
||||
...(overrides.unregisterAgentAbort
|
||||
? { unregisterAgentAbort: overrides.unregisterAgentAbort }
|
||||
: {}),
|
||||
},
|
||||
journalStore: {
|
||||
read: async () => [],
|
||||
@@ -424,3 +438,73 @@ test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)'
|
||||
})
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
|
||||
// service.kill(runId, agentId) 桥接:hooks.agent 必须把 taskRegistrar 的
|
||||
// registerAgentAbort/unregisterAgentAbort 注入 adapterCtx(绑定当前 runId)。
|
||||
// backend 据此把 agentAbort controller 塞进 Map,service.kill 据 agentId 精确 abort。
|
||||
test('agentAdapter ctx 注入 registerAgentAbort/unregisterAgentAbort(绑定 runId 转发 taskRegistrar)', async () => {
|
||||
const registered: Array<{
|
||||
runId: string
|
||||
agentId: number
|
||||
controller: AbortController
|
||||
}> = []
|
||||
const unregistered: Array<{ runId: string; agentId: number }> = []
|
||||
// 捕获 hooks 传给 adapter 的 ctx(验证 register/unregister 已注入且绑定 runId)
|
||||
let capturedCtx: {
|
||||
registerAgentAbort?: (id: number, ac: AbortController) => void
|
||||
unregisterAgentAbort?: (id: number) => void
|
||||
agentId: number
|
||||
runId: string
|
||||
} | null = null
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
id: 'ad',
|
||||
capabilities: { structuredOutput: true },
|
||||
async run(_params, ctx) {
|
||||
capturedCtx = ctx
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
.default('ad')
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
registerAgentAbort: (runId, agentId, controller) =>
|
||||
registered.push({ runId, agentId, controller }),
|
||||
unregisterAgentAbort: (runId, agentId) =>
|
||||
unregistered.push({ runId, agentId }),
|
||||
})
|
||||
await hooks.agent('x')
|
||||
// ctx 含 register/unregister(闭包绑定 runId='r1')
|
||||
expect(capturedCtx).not.toBeNull()
|
||||
expect(typeof capturedCtx!.registerAgentAbort).toBe('function')
|
||||
expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function')
|
||||
// 模拟 backend 调用:注入的闭包把 (agentId, controller) 转发到 taskRegistrar,
|
||||
// 并自动补 runId='r1'(backend 不需要知道 runId)
|
||||
const ac = new AbortController()
|
||||
capturedCtx!.registerAgentAbort!(7, ac)
|
||||
capturedCtx!.unregisterAgentAbort!(7)
|
||||
expect(registered).toEqual([{ runId: 'r1', agentId: 7, controller: ac }])
|
||||
expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }])
|
||||
})
|
||||
|
||||
test('taskRegistrar 未提供 registerAgentAbort → adapterCtx 也不含(hooks 不报错)', async () => {
|
||||
// 不传 registerAgentAbort/unregisterAgentAbort overrides → buildCtx 也不注入 taskRegistrar
|
||||
// hooks 用 optional chaining 跳过,adapterCtx 不含这两个字段
|
||||
let capturedCtx: object | null = null
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
id: 'ad',
|
||||
capabilities: { structuredOutput: true },
|
||||
async run(_params, ctx) {
|
||||
capturedCtx = ctx
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
.default('ad')
|
||||
const { hooks } = buildCtx({ agentAdapterRegistry: registry })
|
||||
await hooks.agent('x')
|
||||
expect(capturedCtx).not.toBeNull()
|
||||
expect(
|
||||
(capturedCtx! as Record<string, unknown>).registerAgentAbort,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -25,11 +25,29 @@ export type AgentAdapterContext = {
|
||||
signal: AbortSignal
|
||||
/** 当前 workflow runId(日志/追踪用)。 */
|
||||
runId: string
|
||||
/**
|
||||
* 引擎层 agent 序号(hooks.agentIdSeq 递增;面板 RunProgress.agents[].id 同源)。
|
||||
* 注意:与 backend 内部创建的 core AgentId(字符串,子 agent 跟踪用)是两个不同概念,
|
||||
* 不可混用。本字段用于 registerAgentAbort/unregisterAgentAbort 的 key,让 service
|
||||
* .kill(runId, agentId) 能精确路由到 backend 创建的 AbortController。
|
||||
*/
|
||||
agentId: number
|
||||
/**
|
||||
* 运行中进度上报(后端循环累计 token/tool 时调用)。可选:独立后端可不实现;
|
||||
* 引擎据此发 agent_progress 事件(闭包带 agentId/runId 关联),面板实时刷新。
|
||||
*/
|
||||
onProgress?: (update: AgentProgressUpdate) => void
|
||||
/**
|
||||
* 注册 agent 级 AbortController(可选)。后端创建 controller 后调此注入 Map,
|
||||
* 让 service.kill(runId, agentId) 能精确中断单个 agent 而不影响其他。
|
||||
* 由 hooks.agent 在 backend.run 调用前注入。
|
||||
*/
|
||||
registerAgentAbort?: (agentId: number, ac: AbortController) => void
|
||||
/**
|
||||
* 注销 agent 级 AbortController(agent 完成或失败时调;幂等)。
|
||||
* 与 registerAgentAbort 配对。
|
||||
*/
|
||||
unregisterAgentAbort?: (agentId: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,13 +117,45 @@ export function makeHooks(
|
||||
const onProgress = (update: AgentProgressUpdate): void => {
|
||||
emit({ type: 'agent_progress', agentId, label, phase, ...update })
|
||||
}
|
||||
const result = registry
|
||||
? await registry.resolve(params).run(params, {
|
||||
// 注入 agent 级 AbortController 注册/注销:backend 创建 controller 后调
|
||||
// registerAgentAbort 注入 ports 层 bindings,service.kill(runId, agentId) 据此
|
||||
// 精确中断单个 agent。registry 不存在(agentRunner 兜底路径)时无 backend 中间层,
|
||||
// ports 层 agentAbortControllers 永远空——单 agent kill 在该路径降级为 no-op。
|
||||
const adapterCtx = registry
|
||||
? {
|
||||
host: ctx.host,
|
||||
signal: ctx.signal,
|
||||
runId: ctx.runId,
|
||||
agentId,
|
||||
onProgress,
|
||||
})
|
||||
...(ctx.ports.taskRegistrar.registerAgentAbort
|
||||
? {
|
||||
registerAgentAbort: (
|
||||
id: number,
|
||||
ac: AbortController,
|
||||
): void => {
|
||||
ctx.ports.taskRegistrar.registerAgentAbort?.(
|
||||
ctx.runId,
|
||||
id,
|
||||
ac,
|
||||
)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(ctx.ports.taskRegistrar.unregisterAgentAbort
|
||||
? {
|
||||
unregisterAgentAbort: (id: number): void => {
|
||||
ctx.ports.taskRegistrar.unregisterAgentAbort?.(
|
||||
ctx.runId,
|
||||
id,
|
||||
)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null
|
||||
const result = registry
|
||||
? await registry.resolve(params).run(params, adapterCtx!)
|
||||
: await ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
|
||||
if (result.kind === 'ok') {
|
||||
ctx.resources.budget.addOutputTokens(result.usage.outputTokens)
|
||||
|
||||
@@ -69,6 +69,21 @@ export type TaskRegistrar = {
|
||||
complete(runId: string, summary?: string): void
|
||||
fail(runId: string, error: string): void
|
||||
kill(runId: string): void
|
||||
/**
|
||||
* 注册 agent 级 AbortController。backend 启动 agent 时调用,让 service
|
||||
* .kill(runId, agentId) 能精确中断单个 agent(不影响同 run 其他 agent)。
|
||||
* 幂等:同 agentId 重复注册覆盖。
|
||||
*/
|
||||
registerAgentAbort?(runId: string, agentId: number, ac: AbortController): void
|
||||
/**
|
||||
* 注销 agent 级 AbortController(agent 完成/失败时调;幂等)。
|
||||
*/
|
||||
unregisterAgentAbort?(runId: string, agentId: number): void
|
||||
/**
|
||||
* 中断单个 agent。返回是否命中(false = agent 已完成/不存在)。
|
||||
* 不影响同 run 其他 agent,workflow 继续跑(被中断 agent 返回 dead → null)。
|
||||
*/
|
||||
killAgent?(runId: string, agentId: number): boolean
|
||||
/** 返回当前待处理的 skip/retry 动作,或 null。 */
|
||||
pendingAction(runId: string): { kind: 'skip' | 'retry' } | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user