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:
claude-code-best
2026-06-14 10:38:15 +08:00
parent 5aa39c36b7
commit bcd6771c48
14 changed files with 592 additions and 25 deletions

View File

@@ -33,6 +33,7 @@ const CTX = {
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
agentId: 1,
}
test('resolve 默认走 default adapterrun 返回结果', async () => {

View File

@@ -24,6 +24,14 @@ type CtxOverrides = Partial<{
truncated: string[]
agentAdapterRegistry: AgentAdapterRegistry
loggerWarn: (msg: string) => void
// taskRegistrar 的 agent 级 abort 绑定agent kill 桥接)。
// 提供后 buildCtx 注入到 ports.taskRegistrarhooks.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 塞进 Mapservice.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()
})

View File

@@ -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 级 AbortControlleragent 完成或失败时调;幂等)。
* 与 registerAgentAbort 配对。
*/
unregisterAgentAbort?: (agentId: number) => void
}
/**

View File

@@ -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 层 bindingsservice.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)

View File

@@ -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 级 AbortControlleragent 完成/失败时调;幂等)。
*/
unregisterAgentAbort?(runId: string, agentId: number): void
/**
* 中断单个 agent。返回是否命中false = agent 已完成/不存在)。
* 不影响同 run 其他 agentworkflow 继续跑(被中断 agent 返回 dead → null
*/
killAgent?(runId: string, agentId: number): boolean
/** 返回当前待处理的 skip/retry 动作,或 null。 */
pendingAction(runId: string): { kind: 'skip' | 'retry' } | null
}