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
}

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')

View File

@@ -5,6 +5,7 @@ import {
type AgentAdapterContext,
type AgentRunParams,
type AgentRunResult,
WorkflowAbortedError,
} from '@claude-code-best/workflow-engine'
import { assembleToolPool } from '../../tools.js'
import { finalizeAgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
@@ -146,14 +147,16 @@ export const claudeCodeBackend: AgentAdapter = {
const appState = toolUseContext.getAppState()
const agentDef = resolveAgentDefinition(params.agentType, toolUseContext)
const model = mapWorkflowModel(params.model)
const agentId = createAgentId()
// coreAgentIdcore 层子 agent 跟踪 ID字符串runAgent 内部用)。
// 与 ctx.agentId引擎 number seq用于面板/killAgent 路由)是两个不同概念,不可混用。
const coreAgentId = createAgentId()
// isolation:'worktree' — 在独立 git worktree 里跑 agent并发写互不冲突。
let worktreeInfo: WorkflowWorktreeInfo | null = null
if (params.isolation === 'worktree') {
try {
worktreeInfo = await createAgentWorktree(
makeWorkflowWorktreeSlug(ctx.runId, agentId),
makeWorkflowWorktreeSlug(ctx.runId, coreAgentId),
)
} catch (e) {
// fail-closed隔离未达成不静默退化为共享 cwd否则并发写数据竞争
@@ -170,6 +173,21 @@ export const claudeCodeBackend: AgentAdapter = {
runWithCwdOverride(worktreeInfo!.worktreePath, fn)
: <T>(fn: () => T): T => fn()
// 桥接 ctx.signal → runAgent.override.abortController。否则 workflow 被 kill
// 时 runAgent 不知道('x' 无效根因abort 信号到不了内部 fetchagent 跑到完成。
// 单 agent kill 走 service.kill(runId, agentId) → ports.taskRegistrar.killAgent →
// agentAbortControllers.get(agentId).abort();同一 controller 接管两条路径。
const agentAbort = new AbortController()
const onParentAbort = (): void => agentAbort.abort()
if (ctx.signal.aborted) {
agentAbort.abort()
} else {
ctx.signal.addEventListener('abort', onParentAbort, { once: true })
}
if (typeof ctx.registerAgentAbort === 'function') {
ctx.registerAgentAbort(ctx.agentId, agentAbort)
}
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: agentDef.permissionMode ?? 'acceptEdits',
@@ -201,9 +219,10 @@ export const claudeCodeBackend: AgentAdapter = {
isAsync: true,
querySource: toolUseContext.options.querySource ?? 'workflow',
availableTools: workerTools,
override: { agentId },
// override 同一对象coreAgentIdcore 子 agent 跟踪)+ abortControllerkill 桥接)。
// runAgent 的 model 是顶层 ModelAliasworkflow 的 model 是任意别名串,
// 类型上不兼容,运行时由 provider 层解析。双重断言透传(优于 as any/never
override: { agentId: coreAgentId, abortController: agentAbort },
...(model ? { model: model as unknown as ModelAlias } : {}),
...(worktreeInfo ? { worktreePath: worktreeInfo.worktreePath } : {}),
})) {
@@ -224,12 +243,23 @@ export const claudeCodeBackend: AgentAdapter = {
}
})
} catch (e) {
// abortkill workflow / kill agent识别后必须重抛 WorkflowAbortedError
// 否则 hooks.agent 会把 abort 当作普通失败吞成 deadworkflow 不知道被 kill
// kill 路径 'x' 无效的另一面:信号虽然到了,但结果被伪装成正常完成)。
if (agentAbort.signal.aborted || (e as Error)?.name === 'AbortError') {
throw new WorkflowAbortedError()
}
logForDebugging(
`workflow sub-agent error (${agentDef.agentType}): ${(e as Error).message}`,
)
logEvent('tengu_workflow_agent', { ok: 0 })
return { kind: 'dead' }
} finally {
// 清理幂等listener removeEventListener / Map.delete 重复调用安全。
if (typeof ctx.unregisterAgentAbort === 'function') {
ctx.unregisterAgentAbort(ctx.agentId)
}
ctx.signal.removeEventListener('abort', onParentAbort)
if (worktreeInfo) {
const info = worktreeInfo
worktreeInfo = null
@@ -237,7 +267,7 @@ export const claudeCodeBackend: AgentAdapter = {
}
}
const finalized = finalizeAgentTool(messages, agentId, {
const finalized = finalizeAgentTool(messages, coreAgentId, {
prompt: params.prompt,
resolvedAgentModel: toolUseContext.options.mainLoopModel,
isBuiltInAgent: isBuiltInAgent(agentDef),

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import { Box, Text, useAnimationFrame } from '@anthropic/ink';
import { Box, Dialog, Text, useAnimationFrame } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { getWorkflowService } from '../service.js';
@@ -47,6 +47,9 @@ export function WorkflowsPanel({
const [focusColumn, setFocusColumn] = useState<FocusColumn>('phases');
const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0);
const [selectedAgentIndex, setSelectedAgentIndex] = useState(0);
// kill 二次确认。null = 无弹窗;'workflow' = 杀整个 run'agent' = 杀当前选中 agent。
// 非 null 时键盘进入 confirm 模式(仅 y/Enter/n/Esc/q 响应)。
const [confirmKill, setConfirmKill] = useState<null | 'agent' | 'workflow'>(null);
// mount 时触发一次扫盘 hydrate 历史 runservice 内部 persistedLoaded flag 守护幂等)。
// 重 mount/重渲染不会重复扫盘flag 进程单例守护。svc 引用稳定getWorkflowService 单例)。
@@ -110,8 +113,18 @@ export function WorkflowsPanel({
if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s + 1, phaseRowCount));
else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length));
},
killFocused: () => {
if (focused) svc.kill(focused.runId);
killAgent: () => {
// 仅在 agents 列聚焦时弹 agent 确认(在 phases 列按 x 无目标no-op
// 选中 agent 由 visibleAgents[clampedAgent] 决定;保存到 confirmKill 后由
// confirmYes 实际执行——避免在两次渲染间 visibleAgents 变化导致误杀。
if (focusColumn !== 'agents' || !focused) return;
const agent = visibleAgents[clampedAgent];
if (!agent) return;
setConfirmKill('agent');
},
killWorkflow: () => {
if (!focused) return;
setConfirmKill('workflow');
},
resumeFocused: () => {
if (!focused) return;
@@ -125,9 +138,27 @@ export function WorkflowsPanel({
.catch(e => onDone(`resume failed: ${(e as Error).message}`));
},
newRun: () => onDone('Tip: start a named workflow with /<name>, or pass name via the Workflow tool.'),
quit: () => onDone(),
quit: () => {
// confirm 模式下 q = 取消确认routeWorkflowKey 已路由到 confirmNo
// 非 confirm 模式才真退出面板。
if (confirmKill !== null) {
setConfirmKill(null);
return;
}
onDone();
},
confirmYes: () => {
if (confirmKill === 'workflow' && focused) {
svc.kill(focused.runId);
} else if (confirmKill === 'agent' && focused) {
const agent = visibleAgents[clampedAgent];
if (agent) svc.killAgent(focused.runId, agent.id);
}
setConfirmKill(null);
},
confirmNo: () => setConfirmKill(null),
};
useWorkflowKeyboard(handlers);
useWorkflowKeyboard(handlers, confirmKill !== null ? 'confirm' : 'normal');
const running = runs.filter(r => r.status === 'running').length;
const done = runs.length - running;
@@ -182,8 +213,31 @@ export function WorkflowsPanel({
</Box>
<Box marginTop={1}>
<Text color="subtle">Tab switch run · / focus · / move · x kill · r resume · q quit</Text>
<Text color="subtle">
{confirmKill !== null
? 'Confirm: y kill · n/Esc cancel'
: 'Tab switch run · ←/→ focus · ↑/↓ move · x kill agent · K kill workflow · r resume · q quit'}
</Text>
</Box>
{confirmKill !== null ? (
<Dialog
title={
confirmKill === 'workflow'
? `Kill workflow "${focused?.workflowName ?? ''}"?`
: `Kill agent "${visibleAgents[clampedAgent]?.label ?? ''}"?`
}
subtitle={
confirmKill === 'workflow'
? 'All in-flight agents will be aborted. Resume will replay from journal.'
: 'Only this agent aborts; other agents in the workflow keep running.'
}
onCancel={() => setConfirmKill(null)}
color="warning"
>
<Text color="subtle">Press y to confirm, or n/Esc to cancel.</Text>
</Dialog>
) : null}
</Box>
);
}

View File

@@ -3,11 +3,15 @@ import { useInput } from '@anthropic/ink'
/** 焦点所在列。 */
export type FocusColumn = 'phases' | 'agents'
/** 键盘模式normal=正常导航confirm=弹了 Dialog等用户 y/n 确认。 */
export type WorkflowKeyboardMode = 'normal' | 'confirm'
/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */
type KeyEvent = {
tab?: boolean
shift?: boolean
escape?: boolean
return?: boolean
leftArrow?: boolean
rightArrow?: boolean
upArrow?: boolean
@@ -22,19 +26,34 @@ export type WorkflowKeyAction =
| 'focusRight'
| 'moveUp'
| 'moveDown'
| 'kill'
| 'killAgent'
| 'killWorkflow'
| 'resume'
| 'newRun'
| 'quit'
| 'confirmYes'
| 'confirmNo'
export function routeWorkflowKey(
input: string,
key: KeyEvent,
mode: WorkflowKeyboardMode = 'normal',
): WorkflowKeyAction | null {
// confirm 模式:仅 y/Enter 确认n/Esc/q 取消,其他键吞掉(防误触)
if (mode === 'confirm') {
if (input === 'y' || input === 'Y' || key.return) return 'confirmYes'
if (input === 'n' || input === 'N' || key.escape || input === 'q') {
return 'confirmNo'
}
return null
}
// @anthropic/ink 的 key.tab 对 Tab 键置 true个别环境回落到 '\t'
if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab'
if (key.escape || input === 'q') return 'quit'
if (input === 'x') return 'kill'
// 大写 K = 杀整个 workflow小写 x = 杀当前选中 agent仅 agents 列)。
// 大小写区分避免 x 误触发 workflow killK 显式需要 Shift 暗示"重操作"。
if (input === 'K') return 'killWorkflow'
if (input === 'x') return 'killAgent'
if (input === 'r') return 'resume'
if (input === 'n') return 'newRun'
if (key.leftArrow) return 'focusLeft'
@@ -52,10 +71,17 @@ export type WorkflowKeyboardHandlers = {
focusRight: () => void
moveUp: () => void
moveDown: () => void
killFocused: () => void
/** 请求杀当前选中 agentpanel 弹 Dialog 二次确认)。 */
killAgent: () => void
/** 请求杀整个 workflowpanel 弹 Dialog 二次确认)。 */
killWorkflow: () => void
resumeFocused: () => void
newRun: () => void
quit: () => void
/** confirm 模式下用户确认y/Enter。 */
confirmYes: () => void
/** confirm 模式下用户取消n/Esc/q。 */
confirmNo: () => void
}
/**
@@ -63,11 +89,16 @@ export type WorkflowKeyboardHandlers = {
* - Tab / Shift+Tab切顶部 run tab
* - ← / →phases ↔ agents 焦点切换
* - ↑ / ↓:当前焦点列内移动
* - x kill · r resume · n new · q / Esc quit
* - x kill 单 agent · K kill 整个 workflow带 Dialog 二次确认) · r resume · n new · q / Esc quit
*
* @param mode confirm 时只接受 y/n/Esc/q其他键吞掉——避免在确认弹窗里误导航。
*/
export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void {
export function useWorkflowKeyboard(
h: WorkflowKeyboardHandlers,
mode: WorkflowKeyboardMode = 'normal',
): void {
useInput((input, key) => {
const action = routeWorkflowKey(input, key as KeyEvent)
const action = routeWorkflowKey(input, key as KeyEvent, mode)
if (action === null) return
switch (action) {
case 'nextTab':
@@ -88,8 +119,11 @@ export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void {
case 'moveDown':
h.moveDown()
break
case 'kill':
h.killFocused()
case 'killAgent':
h.killAgent()
break
case 'killWorkflow':
h.killWorkflow()
break
case 'resume':
h.resumeFocused()
@@ -100,6 +134,12 @@ export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void {
case 'quit':
h.quit()
break
case 'confirmYes':
h.confirmYes()
break
case 'confirmNo':
h.confirmNo()
break
}
})
}

View File

@@ -34,6 +34,8 @@ type RunBinding = {
setAppState: SetAppState
abortController: AbortController
workflowName: string
/** agentId → AbortController。backend 启动 agent 时注册killAgent 据此精确中断。 */
agentAbortControllers: Map<number, AbortController>
}
/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */
@@ -107,6 +109,7 @@ export function createWorkflowPorts(opts: {
setAppState,
abortController,
workflowName: regOpts.workflowName,
agentAbortControllers: new Map(),
})
logForDebugging(
`workflow task registered: ${runId} (${regOpts.workflowName})`,
@@ -131,8 +134,40 @@ export function createWorkflowPorts(opts: {
const b = bindings.get(runId)
if (!b) return
killWorkflowTask(b.taskId, b.setAppState) // 内部 abort controller
// 杀 run 同时中断所有 in-flight agent防止 backend 没接到 task abort 的极端时序)
for (const ac of b.agentAbortControllers.values()) {
try {
ac.abort()
} catch {
// no-opabort 内部不会抛,但 fail-closed
}
}
b.agentAbortControllers.clear()
bindings.delete(runId)
},
registerAgentAbort(runId, agentId, ac) {
const b = bindings.get(runId)
if (!b) return
b.agentAbortControllers.set(agentId, ac)
},
unregisterAgentAbort(runId, agentId) {
const b = bindings.get(runId)
if (!b) return
b.agentAbortControllers.delete(agentId)
},
killAgent(runId, agentId) {
const b = bindings.get(runId)
if (!b) return false
const ac = b.agentAbortControllers.get(agentId)
if (!ac) return false
try {
ac.abort()
} catch {
// no-op
}
b.agentAbortControllers.delete(agentId)
return true
},
pendingAction() {
return null // v1skip/retry 不接线seam 保留)
},

View File

@@ -59,6 +59,11 @@ export type WorkflowService = {
canUseTool: CanUseToolFn,
): Promise<{ runId: string; scriptPath?: string }>
kill(runId: string): void
/**
* 中断单个 agent不影响同 run 其他 agentworkflow 继续跑)。
* 返回是否命中false = agent 已完成/不存在。agent 被 abort 后返回 dead → null。
*/
killAgent(runId: string, agentId: number): boolean
/**
* 进程退出 / 配置卸载时清理:杀掉所有 running run避免孤儿 task。
* 已完成/失败的 run 不受影响。幂等——多次调用安全。
@@ -243,6 +248,9 @@ export function makeService(
kill(runId) {
ports.taskRegistrar.kill(runId)
},
killAgent(runId, agentId) {
return ports.taskRegistrar.killAgent?.(runId, agentId) ?? false
},
shutdown() {
// 仅杀 running已完成/失败的 run taskRegistrar 已回收 bindingkill 是 no-op。