mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +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(
|
||||
|
||||
@@ -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=null(runs 清空):不触发
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 是同一 runId,prev 是 running,curr 是 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 的状态转换触发:切到已完成的 tab(prev 是别的 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 title(0 = All = undefined)
|
||||
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user