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

@@ -76,6 +76,7 @@ mock.module('src/utils/worktree.js', () => ({
},
}))
import { WorkflowAbortedError } from '@claude-code-best/workflow-engine'
import {
claudeCodeBackend,
resolveAgentDefinition,
@@ -108,6 +109,7 @@ function ctx() {
}),
signal: new AbortController().signal,
runId: 'r1',
agentId: 1,
}
}
@@ -188,6 +190,85 @@ test('runAgent 抛错 → dead', async () => {
expect(res.kind).toBe('dead')
})
// 下面三组测试覆盖 'x' 无效修复backend 必须把 ctx.signal 桥接到 runAgent.override
// .abortController并把 AbortError 识别为 abortthrow WorkflowAbortedError而非吞成 dead
// 还要验证 registerAgentAbort 注入,让 service.kill(runId, agentId) 能精确中断单个 agent。
test('ctx.signal 预 abort → backend 桥接override.abortController.signal.aborted=true', async () => {
// 用 capturedOverride 暴露 backend 创建的 agentAbortmock 收到的 override.abortController
let capturedController: AbortController | undefined
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
runAgent: async function* (opts: {
override?: { abortController?: AbortController }
}) {
capturedController = opts.override?.abortController
yield {
type: 'assistant',
message: { content: [{ type: 'text', text: 'x' }] },
}
},
}),
)
const parentAbort = new AbortController()
parentAbort.abort()
// mock 不抛 → backend 走正常返回路径;但桥接 `if (ctx.signal.aborted) agentAbort.abort()`
// 已同步触发capturedController.signal.aborted 必为 truekill 桥接根因)
await claudeCodeBackend.run(
{ prompt: 'pre-aborted' },
{ ...ctx(), signal: parentAbort.signal },
)
expect(capturedController?.signal.aborted).toBe(true)
})
test('runAgent 抛 AbortError → backend throw WorkflowAbortedError不吞成 dead', async () => {
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
// biome-ignore lint/correctness/useYield: 故意抛 AbortError 测识别分支
runAgent: async function* () {
const e = new Error('aborted by parent')
e.name = 'AbortError'
throw e
},
}),
)
await expect(
claudeCodeBackend.run({ prompt: 'abort' }, ctx()),
).rejects.toBeInstanceOf(WorkflowAbortedError)
})
test('registerAgentAbort/unregisterAgentAbort 注入key=ctx.agentId数字controller 来自桥接', async () => {
// 恢复默认 mock上一个测试把它改成抛 AbortError 了)
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
runAgent: async function* () {
yield {
type: 'assistant',
message: { content: [{ type: 'text', text: 'agent-text' }] },
}
},
}),
)
const registered: Array<{ id: number; controller: AbortController }> = []
const unregistered: number[] = []
await claudeCodeBackend.run(
{ prompt: 'wiring' },
{
...ctx(),
agentId: 42,
registerAgentAbort: (id, ac) => registered.push({ id, controller: ac }),
unregisterAgentAbort: id => unregistered.push(id),
},
)
expect(registered).toHaveLength(1)
expect(registered[0]?.id).toBe(42) // 引擎数字 agentId非 coreAgentId 字符串)
expect(registered[0]?.controller).toBeInstanceOf(AbortController)
expect(unregistered).toEqual([42]) // finally 清理幂等
})
test('id 与 capabilities 形状', () => {
expect(claudeCodeBackend.id).toBe('claude-code')
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)

View File

@@ -93,6 +93,95 @@ test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppSta
ports.taskRegistrar.kill(runId)
})
// agent 级 kill 桥接register → killAgent 精确中断kill(runId) 顺带 abort 所有 agent。
test('taskRegistrar agentAbortControllersregister/killAgent 精确中断kill(runId) 批量 abort', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
// 实现always provides these — cast 把 optional 拍平为 required避免每行 ! 断言)
const tr = ports.taskRegistrar as Required<typeof ports.taskRegistrar>
const state = { tasks: {} } as unknown as AppState
const setAppState: SetAppState = f => {
Object.assign(state, f(state))
}
const hostCtx = ports.hostFactory({
context: { agentId: 'a-1', toolUseId: 'tu-1', setAppState },
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: {} as never,
})
const { runId } = tr.register(
{
workflowName: 'wf',
summary: 'summary',
workflowFile: 'wf.ts',
toolUseId: 'tu-1',
},
hostCtx.handle,
)
// 注册两个 agent 的 AbortController模拟 backend 在启动 agent 时调用)
const ac1 = new AbortController()
const ac2 = new AbortController()
tr.registerAgentAbort(runId, 1, ac1)
tr.registerAgentAbort(runId, 2, ac2)
expect(ac1.signal.aborted).toBe(false)
expect(ac2.signal.aborted).toBe(false)
// killAgent 精确中断 agent #1仅 ac1 abortac2 不受影响
expect(tr.killAgent(runId, 1)).toBe(true)
expect(ac1.signal.aborted).toBe(true)
expect(ac2.signal.aborted).toBe(false)
// 重复 kill 同 agentcontroller 已 delete返回 false幂等
expect(tr.killAgent(runId, 1)).toBe(false)
// 未知 agentId / 未知 runId 安全返回 false
expect(tr.killAgent(runId, 999)).toBe(false)
expect(tr.killAgent('nope', 1)).toBe(false)
// kill(runId) 批量 abort 剩余 agentac2
tr.kill(runId)
expect(ac2.signal.aborted).toBe(true)
// run 终态后 binding 已回收:再 killAgent 返回 false
expect(tr.killAgent(runId, 2)).toBe(false)
})
test('unregisterAgentAbort 从 Map 删除backend finally 清理幂等)', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
const tr = ports.taskRegistrar as Required<typeof ports.taskRegistrar>
const state = { tasks: {} } as unknown as AppState
const setAppState: SetAppState = f => {
Object.assign(state, f(state))
}
const hostCtx = ports.hostFactory({
context: { agentId: 'a-1', toolUseId: 'tu-1', setAppState },
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: {} as never,
})
const { runId } = tr.register(
{
workflowName: 'wf',
summary: 'summary',
workflowFile: 'wf.ts',
toolUseId: 'tu-1',
},
hostCtx.handle,
)
const ac = new AbortController()
tr.registerAgentAbort(runId, 5, ac)
// 注销后 killAgent 无目标,返 false不抛
tr.unregisterAgentAbort(runId, 5)
expect(tr.killAgent(runId, 5)).toBe(false)
// 重复注销幂等backend finally 不抛)
expect(() => tr.unregisterAgentAbort(runId, 5)).not.toThrow()
// 未知 runId 安全 no-op
expect(() => tr.unregisterAgentAbort('nope', 5)).not.toThrow()
})
test('hostFactory.cwd 与 journalStore 同根getProjectRoot—— 修复 K 回归', () => {
// 历史 bughostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot()
// 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。

View File

@@ -27,6 +27,14 @@ type RegistrarCall =
| { kind: 'complete'; runId: string; summary?: string }
| { kind: 'fail'; runId: string; error?: string }
| { kind: 'kill'; runId: string }
| {
kind: 'registerAgentAbort'
runId: string
agentId: number
controller: AbortController
}
| { kind: 'unregisterAgentAbort'; runId: string; agentId: number }
| { kind: 'killAgent'; runId: string; agentId: number }
function fakePorts(
opts: {
@@ -41,14 +49,18 @@ function fakePorts(
ports: WorkflowPorts
store: ReturnType<typeof createProgressStoreFromBus>
killed: string[]
/** taskRegistrar 调用记录complete/fail/kill。 */
/** taskRegistrar 调用记录complete/fail/kill/registerAgentAbort/...)。 */
calls: RegistrarCall[]
/** runId → (agentId → AbortController)。测试模拟 backend 注册用。 */
agentBindings: Map<string, Map<number, AbortController>>
} {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const killed: string[] = []
const calls: RegistrarCall[] = []
const bindings = new Map<string, { abort: AbortController }>()
// agentId → AbortController每个 runId 独立。killAgent 据此精确中断。
const agentBindings = new Map<string, Map<number, AbortController>>()
let seq = 0
const ports = {
// hostFactory 实际不被 service.launch 路径调用service 自建 host handle
@@ -93,6 +105,7 @@ function fakePorts(
seq += 1
const runId = `run-${seq}`
bindings.set(runId, { abort })
agentBindings.set(runId, new Map())
return { runId, signal: abort.signal }
},
complete: (runId: string, summary?: string) => {
@@ -106,6 +119,31 @@ function fakePorts(
calls.push({ kind: 'kill', runId })
bindings.get(runId)?.abort.abort()
},
registerAgentAbort: (
runId: string,
agentId: number,
controller: AbortController,
) => {
calls.push({
kind: 'registerAgentAbort',
runId,
agentId,
controller,
})
agentBindings.get(runId)?.set(agentId, controller)
},
unregisterAgentAbort: (runId: string, agentId: number) => {
calls.push({ kind: 'unregisterAgentAbort', runId, agentId })
agentBindings.get(runId)?.delete(agentId)
},
killAgent: (runId: string, agentId: number) => {
calls.push({ kind: 'killAgent', runId, agentId })
const ac = agentBindings.get(runId)?.get(agentId)
if (!ac) return false
ac.abort()
agentBindings.get(runId)!.delete(agentId)
return true
},
pendingAction: () => null,
},
journalStore: {
@@ -120,7 +158,7 @@ function fakePorts(
warn: () => {},
},
} as unknown as WorkflowPorts
return { ports, store, killed, calls }
return { ports, store, killed, calls, agentBindings }
}
const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never
@@ -184,6 +222,33 @@ test('kill 走 taskRegistrar.kill', async () => {
expect(killed).toContain(runId)
})
test('killAgent 走 taskRegistrar.killAgent精确中断单个 agent', async () => {
__resetWorkflowServiceForTests()
const { ports, store, calls, agentBindings } = fakePorts()
const svc = makeService(ports, store)
const { runId } = await svc.launch(
{ script: `return agent('x')` },
stubTUC,
stubCanUseTool,
)
// 模拟 backend 启动 agent 时注册 AbortController
const ac = new AbortController()
agentBindings.get(runId)!.set(7, ac)
// service.killAgent 路由到 taskRegistrar.killAgent后者真 abort 对应 controller
expect(svc.killAgent(runId, 7)).toBe(true)
expect(ac.signal.aborted).toBe(true)
expect(
calls.some(
c => c.kind === 'killAgent' && c.runId === runId && c.agentId === 7,
),
).toBe(true)
// 已 abort 后 controller 从 Map 删除:再次 killAgent 同 agent 返 false幂等
expect(svc.killAgent(runId, 7)).toBe(false)
// 未知 agentId / 未知 runId 安全返 false
expect(svc.killAgent(runId, 999)).toBe(false)
expect(svc.killAgent('nope', 1)).toBe(false)
})
test('listRuns/subscribe 来自 store', () => {
__resetWorkflowServiceForTests()
const { ports, store } = fakePorts()

View File

@@ -11,12 +11,27 @@ test('q / Esc → quit', () => {
expect(routeWorkflowKey('', { escape: true })).toBe('quit')
})
test('x → killr → resumen → newRun', () => {
expect(routeWorkflowKey('x', {})).toBe('kill')
test('x → killAgentK → killWorkflowr → resumen → newRun', () => {
expect(routeWorkflowKey('x', {})).toBe('killAgent')
expect(routeWorkflowKey('K', {})).toBe('killWorkflow')
expect(routeWorkflowKey('r', {})).toBe('resume')
expect(routeWorkflowKey('n', {})).toBe('newRun')
})
test('confirm 模式y/Enter → confirmYesn/Esc/q → confirmNo其他键 → null', () => {
expect(routeWorkflowKey('y', {}, 'confirm')).toBe('confirmYes')
expect(routeWorkflowKey('Y', {}, 'confirm')).toBe('confirmYes')
expect(routeWorkflowKey('', { return: true }, 'confirm')).toBe('confirmYes')
expect(routeWorkflowKey('n', {}, 'confirm')).toBe('confirmNo')
expect(routeWorkflowKey('N', {}, 'confirm')).toBe('confirmNo')
expect(routeWorkflowKey('', { escape: true }, 'confirm')).toBe('confirmNo')
expect(routeWorkflowKey('q', {}, 'confirm')).toBe('confirmNo')
// confirm 模式吞掉导航/编辑键,防误触
expect(routeWorkflowKey('x', {}, 'confirm')).toBeNull()
expect(routeWorkflowKey('', { tab: true }, 'confirm')).toBeNull()
expect(routeWorkflowKey('', { upArrow: true }, 'confirm')).toBeNull()
})
test('←/→ 切焦点列;↑/↓ 列内移动', () => {
expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft')
expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight')