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:
claude-code-best
2026-06-14 14:45:02 +08:00
parent 15216eb2e6
commit 8bc1a33f3c
4 changed files with 187 additions and 23 deletions

View File

@@ -332,6 +332,100 @@ test('发射 run_started含 workflowName与 run_done 事件', async () =>
}
})
// 终态前补发当前 phase 的 phase_donehook.phase 只在切换时 emit 上一个的 done
// 最后一个 phase 无后续切换 → UI 左栏会永远显示 running。验证三路径都补发。
test('终态前补发 currentPhase 的 phase_donecompleted 路径)', 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_donekilled 路径)', 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_donecurrentPhase 为 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 {

View File

@@ -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 左栏
// 会永远显示 runningagent 列表已 ✓ 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(

View File

@@ -5,7 +5,7 @@ import { wrappedRender as render } from '@anthropic/ink';
import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
import type { RunProgress } from '../progress/store.js';
import { call as panelCall } from '../panel/panelCall.js';
import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { clampSelected, isRunTerminatedTransition, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { truncateLabel } from '../panel/AgentList.js';
import { STATUS_DOT } from '../panel/status.js';
import { __resetWorkflowServiceForTests, getWorkflowService } from '../service.js';
@@ -162,3 +162,36 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
__resetWorkflowServiceForTests();
}
});
// focused run 从 running 转 terminal 时面板自动 onDone()800ms 延迟让用户看到终态)。
// 仅同 runId 的状态转换触发:切到已完成 tab 不退出;打开历史面板也不退出。
// 转换判定逻辑抽成 isRunTerminatedTransition 纯函数便于离线单测Ink test 模式不
// 自动 pump concurrent 状态更新,集成测试不可靠)。
test('isRunTerminatedTransition同 runId running → terminal 触发;其它情况不触发', () => {
const running = { runId: 'r1', status: 'running' as const };
const completed = { runId: 'r1', status: 'completed' as const };
const failed = { runId: 'r1', status: 'failed' as const };
const killed = { runId: 'r1', status: 'killed' as const };
// 同 run running → terminal三种 terminal 都触发
expect(isRunTerminatedTransition(running, completed)).toBe(true);
expect(isRunTerminatedTransition(running, failed)).toBe(true);
expect(isRunTerminatedTransition(running, killed)).toBe(true);
// prev=null打开历史面板不触发
expect(isRunTerminatedTransition(null, completed)).toBe(false);
// curr=nullruns 清空):不触发
expect(isRunTerminatedTransition(running, null)).toBe(false);
// 不同 runId切 tab不触发
expect(isRunTerminatedTransition({ runId: 'r1', status: 'running' }, { runId: 'r2', status: 'completed' })).toBe(
false,
);
// 同 run 但 prev 非 running已是 terminal 又重渲染):不触发
expect(isRunTerminatedTransition(completed, completed)).toBe(false);
expect(isRunTerminatedTransition(killed, completed)).toBe(false);
// 同 run running → running无变化不触发
expect(isRunTerminatedTransition(running, running)).toBe(false);
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Box, Dialog, Text, useAnimationFrame } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
@@ -22,6 +22,25 @@ export function clampSelected(selected: number, len: number): number {
return Math.min(n, len - 1);
}
/**
* 判断 focused run 是否完成了 running → terminal 的状态转换(用于面板自动退出)。
* 抽成纯函数便于单测;面板 useEffect 内部直接调用。
*
* 触发条件prev 与 curr 是同一 runIdprev 是 runningcurr 是 completed/failed/killed。
* - 打开历史面板prev=null不触发
* - 切到已完成 tab不同 runId不触发
* - 同 run running → terminal触发
*/
export function isRunTerminatedTransition(
prev: { runId: string; status: RunProgress['status'] } | null,
curr: { runId: string; status: RunProgress['status'] } | null,
): boolean {
if (!prev || !curr) return false;
if (prev.runId !== curr.runId) return false;
if (prev.status !== 'running') return false;
return curr.status === 'completed' || curr.status === 'failed' || curr.status === 'killed';
}
/**
* /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。
*
@@ -74,6 +93,21 @@ export function WorkflowsPanel({
const phaseRowCount = phases.length + 1;
const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount);
// focused run 从 running 转 terminal 时自动退出面板800ms 延迟让用户看到 ✓/✗ 终态)。
// 仅同 runId 的状态转换触发:切到已完成的 tabprev 是别的 run不退出打开历史面板
// prev=null也不退出。否则 agent 在 Workflow tool 等结果时被面板挡住,用户必须手动 q。
const prevFocusedRef = useRef<{ runId: string; status: RunProgress['status'] } | null>(null);
useEffect(() => {
const curr = focused ? { runId: focused.runId, status: focused.status } : null;
const prev = prevFocusedRef.current;
prevFocusedRef.current = curr;
if (!isRunTerminatedTransition(prev, curr)) return;
const timer = setTimeout(() => onDone(), 800);
return (): void => {
clearTimeout(timer);
};
}, [focused?.runId, focused?.status, onDone]);
// 选中 phase title0 = All = undefined
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;