mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱
ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase() hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一 个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威, 于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。 改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done' 权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看 actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。 不改 store 状态语义,已有 state.json 无需迁移。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -63,6 +63,57 @@ test('mergePhases: actual but undeclared phase appended to the end', () => {
|
|||||||
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
|
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: scripts that pass opts.phase directly to agent() without a phase() hook call
|
||||||
|
// (the ultracode canonical pipeline pattern). phase_started is never emitted for those phases,
|
||||||
|
// so run.phases lacks them. The sidebar used to show them as pending forever while agents were
|
||||||
|
// clearly running under them — and worse, the previous phase stayed "running" because phase_done
|
||||||
|
// only fires on the next phase() call. Derive status from agents when no actual record exists.
|
||||||
|
test('mergePhases: derives status from agents when phase_started was never emitted', () => {
|
||||||
|
// Mirrors the real .claude/workflow-runs/wnxct9u3q/script.js shape:
|
||||||
|
// phase('Map') called, 8 Map agents done; pipeline stage with phase:'Find' running (1/4);
|
||||||
|
// Verify / Synthesize declared but not started; phase('Synthesize') not yet reached so
|
||||||
|
// phase_done Map has not fired either — actual Map is still 'running'.
|
||||||
|
const r = run({
|
||||||
|
declaredPhases: ['Map', 'Find', 'Verify', 'Synthesize'],
|
||||||
|
phases: [{ title: 'Map', status: 'running' }],
|
||||||
|
agents: [
|
||||||
|
...Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
phase: 'Map',
|
||||||
|
status: 'done' as const,
|
||||||
|
resultKind: 'ok',
|
||||||
|
})),
|
||||||
|
{ id: 100, phase: 'Find', status: 'done', resultKind: 'ok' },
|
||||||
|
{ id: 101, phase: 'Find', status: 'running' },
|
||||||
|
{ id: 102, phase: 'Find', status: 'running' },
|
||||||
|
{ id: 103, phase: 'Find', status: 'running' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(mergePhases(r)).toEqual([
|
||||||
|
{ title: 'Map', status: 'done', done: 8, total: 8 },
|
||||||
|
{ title: 'Find', status: 'running', done: 1, total: 4 },
|
||||||
|
{ title: 'Verify', status: 'pending', done: 0, total: 0 },
|
||||||
|
{ title: 'Synthesize', status: 'pending', done: 0, total: 0 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// A phase that appears only on agents (not in declaredPhases, not in run.phases) is still
|
||||||
|
// surfaced so the user sees it in the sidebar.
|
||||||
|
test('mergePhases: phase only present on agents is appended and derived from agent states', () => {
|
||||||
|
const r = run({
|
||||||
|
declaredPhases: ['Scan'],
|
||||||
|
phases: [],
|
||||||
|
agents: [
|
||||||
|
{ id: 1, phase: 'AdhocFromAgent', status: 'running' },
|
||||||
|
{ id: 2, phase: 'AdhocFromAgent', status: 'done', resultKind: 'ok' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(mergePhases(r)).toEqual([
|
||||||
|
{ title: 'Scan', status: 'pending', done: 0, total: 0 },
|
||||||
|
{ title: 'AdhocFromAgent', status: 'running', done: 1, total: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => {
|
test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => {
|
||||||
const agents: AgentProgress[] = [
|
const agents: AgentProgress[] = [
|
||||||
{ id: 1, phase: 'A', status: 'running' },
|
{ id: 1, phase: 'A', status: 'running' },
|
||||||
|
|||||||
@@ -13,9 +13,40 @@ export type MergedPhase = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge declaredPhases (declared by meta) and run.phases (actually running/done):
|
* Derive a phase's sidebar status from the actual record + the agents grouped under it.
|
||||||
* - Declared order takes priority; phases present in actual but not declared are appended at the end.
|
*
|
||||||
* - No actual record -> pending; otherwise take the actual status.
|
* The actual record comes from `phase_started`/`phase_done` events. Scripts that follow the
|
||||||
|
* ultracode canonical pipeline pattern pass `opts.phase` directly to `agent()` inside
|
||||||
|
* `pipeline()`/`parallel()` stages and never call `phase()` for those phases — so no
|
||||||
|
* `phase_started` ever fires and `run.phases` lacks them. Worse, because `phase_done` only
|
||||||
|
* emits when the *next* `phase()` runs, the previous phase stays "running" in `run.phases`
|
||||||
|
* even after all its agents finish.
|
||||||
|
*
|
||||||
|
* Rules (checked in order):
|
||||||
|
* 1. `phase_done` already fired → done is authoritative, respect it.
|
||||||
|
* 2. Agents exist under this phase → derive from their states
|
||||||
|
* (all done → done; otherwise → running). This is what the user actually sees.
|
||||||
|
* 3. No agents yet → fall back to the actual record
|
||||||
|
* (`running` if `phase()` was called and is still active, else pending).
|
||||||
|
*/
|
||||||
|
function derivePhaseStatus(
|
||||||
|
actual: { status: 'running' | 'done' } | undefined,
|
||||||
|
inPhase: AgentProgress[],
|
||||||
|
): PhaseStatus {
|
||||||
|
if (actual?.status === 'done') return 'done'
|
||||||
|
if (inPhase.length > 0) {
|
||||||
|
return inPhase.every(a => a.status === 'done') ? 'done' : 'running'
|
||||||
|
}
|
||||||
|
return actual?.status === 'running' ? 'running' : 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge declaredPhases (declared by meta), run.phases (actually running/done),
|
||||||
|
* and phases that appear only on agents:
|
||||||
|
* - Declared order takes priority; then actual-but-undeclared; then agent-only phases.
|
||||||
|
* Agent-only phases surface in the sidebar even when the script never called `phase()`
|
||||||
|
* for them — otherwise the user sees agents running under a phase that isn't listed.
|
||||||
|
* - Status is derived via {@link derivePhaseStatus}.
|
||||||
* - done/total = done under that phase / total agents under that phase.
|
* - done/total = done under that phase / total agents under that phase.
|
||||||
*/
|
*/
|
||||||
export function mergePhases(
|
export function mergePhases(
|
||||||
@@ -28,17 +59,22 @@ export function mergePhases(
|
|||||||
if (seen.has(title)) return
|
if (seen.has(title)) return
|
||||||
seen.add(title)
|
seen.add(title)
|
||||||
const actual = actualByTitle.get(title)
|
const actual = actualByTitle.get(title)
|
||||||
const status: PhaseStatus = !actual ? 'pending' : actual.status
|
|
||||||
const inPhase = run.agents.filter(a => a.phase === title)
|
const inPhase = run.agents.filter(a => a.phase === title)
|
||||||
out.push({
|
out.push({
|
||||||
title,
|
title,
|
||||||
status,
|
status: derivePhaseStatus(actual, inPhase),
|
||||||
done: inPhase.filter(a => a.status === 'done').length,
|
done: inPhase.filter(a => a.status === 'done').length,
|
||||||
total: inPhase.length,
|
total: inPhase.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const t of run.declaredPhases) push(t)
|
for (const t of run.declaredPhases) push(t)
|
||||||
for (const p of run.phases) push(p.title)
|
for (const p of run.phases) push(p.title)
|
||||||
|
// Scripts that pass opts.phase directly to agent() (the ultracode pipeline pattern)
|
||||||
|
// may have agents grouped under phases that never got a phase() call — surface them
|
||||||
|
// so the sidebar reflects every phase the user can actually observe agents running in.
|
||||||
|
for (const a of run.agents) {
|
||||||
|
if (a.phase) push(a.phase)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user