mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换
runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done, UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done 之前补发 emitTerminalPhaseDone。 WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal, 面板 useEffect 检测到转换后自动退出聚焦。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -332,6 +332,100 @@ test('发射 run_started(含 workflowName)与 run_done 事件', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
// 终态前补发当前 phase 的 phase_done:hook.phase 只在切换时 emit 上一个的 done,
|
||||
// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。
|
||||
test('终态前补发 currentPhase 的 phase_done(completed 路径)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
await runWorkflow({
|
||||
script: `phase('Review')\nreturn agent('x')`,
|
||||
runId: 'run-phase-done',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// Review 的 phase_started + phase_done 都应存在(done 来自终态前补发)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_started' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_done' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
// 顺序:phase_done 必须在 run_done 之前(reducer 不依赖顺序,但事件流语义清晰)
|
||||
const lastPhaseDone = Math.max(
|
||||
0,
|
||||
...events.map((e, i) => (e.type === 'phase_done' ? i : -1)),
|
||||
)
|
||||
const runDoneIdx = events.findIndex(e => e.type === 'run_done')
|
||||
expect(runDoneIdx).toBeGreaterThan(0)
|
||||
expect(lastPhaseDone).toBeLessThan(runDoneIdx)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('终态前补发 currentPhase 的 phase_done(killed 路径)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
await runWorkflow({
|
||||
script: `phase('Run')\nreturn agent('x')`,
|
||||
runId: 'run-kill-phase',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: ac.signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(events.some(e => e.type === 'phase_done' && e.phase === 'Run')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'killed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('无 phase() 调用 → 终态不补发 phase_done(currentPhase 为 null)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const { ports, events } = portsWithEvents(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
await runWorkflow({
|
||||
script: `return agent('x')`,
|
||||
runId: 'run-no-phase',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// 没有 phase() → currentPhase 为 null → 终态不补发 phase_done
|
||||
expect(events.some(e => e.type === 'phase_done')).toBe(false)
|
||||
expect(events.some(e => e.type === 'phase_started')).toBe(false)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'completed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('未传 workflowName 时从 meta.name 推导', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
|
||||
@@ -100,37 +100,40 @@ export async function runWorkflow(
|
||||
|
||||
const hooks = makeHooks(ctx, runSubWorkflow)
|
||||
|
||||
// hook.phase 只在切换 phase 时 emit 上一个 phase 的 phase_done;脚本结束时
|
||||
// currentPhase 是最后一个 phase,没有任何后续 phase() 触发其 phase_done → UI 左栏
|
||||
// 会永远显示 running(agent 列表已 ✓ done)。终态前补一条,所有 path 共用。
|
||||
const emitTerminalPhaseDone = (): void => {
|
||||
if (!ctx.currentPhase) return
|
||||
ports.progressEmitter.emit({
|
||||
type: 'phase_done',
|
||||
runId: opts.runId,
|
||||
phase: ctx.currentPhase,
|
||||
})
|
||||
}
|
||||
|
||||
let result: WorkflowRunResult
|
||||
try {
|
||||
const returnValue = await parsed.execute(
|
||||
hooks,
|
||||
opts.args,
|
||||
ctx.resources.budget,
|
||||
)
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_done',
|
||||
runId: opts.runId,
|
||||
status: 'completed',
|
||||
returnValue,
|
||||
})
|
||||
return { status: 'completed', returnValue }
|
||||
result = { status: 'completed', returnValue }
|
||||
} catch (e) {
|
||||
if (e instanceof WorkflowAbortedError) {
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_done',
|
||||
runId: opts.runId,
|
||||
status: 'killed',
|
||||
})
|
||||
return { status: 'killed' }
|
||||
result = { status: 'killed' }
|
||||
} else {
|
||||
result = { status: 'failed', error: (e as Error).message }
|
||||
}
|
||||
const error = (e as Error).message
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_done',
|
||||
runId: opts.runId,
|
||||
status: 'failed',
|
||||
error,
|
||||
})
|
||||
return { status: 'failed', error }
|
||||
}
|
||||
emitTerminalPhaseDone()
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_done',
|
||||
runId: opts.runId,
|
||||
...result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function resolveSubScript(
|
||||
|
||||
Reference in New Issue
Block a user