mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05:51 +00:00
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>
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
import type { AgentProgress, RunProgress } from '../progress/store.js'
|
|
import type { PhaseStatus } from './status.js'
|
|
|
|
/** Title of the fixed "no filter" item (first row of the sidebar). */
|
|
export const ALL_PHASE = 'All'
|
|
|
|
/** Merged phase (including pending), with done/total counts of agents under that phase. */
|
|
export type MergedPhase = {
|
|
title: string
|
|
status: PhaseStatus
|
|
done: number
|
|
total: number
|
|
}
|
|
|
|
/**
|
|
* Derive a phase's sidebar status from the actual record + the agents grouped under it.
|
|
*
|
|
* 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.
|
|
*/
|
|
export function mergePhases(
|
|
run: Pick<RunProgress, 'declaredPhases' | 'phases' | 'agents'>,
|
|
): MergedPhase[] {
|
|
const actualByTitle = new Map(run.phases.map(p => [p.title, p]))
|
|
const seen = new Set<string>()
|
|
const out: MergedPhase[] = []
|
|
const push = (title: string): void => {
|
|
if (seen.has(title)) return
|
|
seen.add(title)
|
|
const actual = actualByTitle.get(title)
|
|
const inPhase = run.agents.filter(a => a.phase === title)
|
|
out.push({
|
|
title,
|
|
status: derivePhaseStatus(actual, inPhase),
|
|
done: inPhase.filter(a => a.status === 'done').length,
|
|
total: inPhase.length,
|
|
})
|
|
}
|
|
for (const t of run.declaredPhases) push(t)
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Filter agents by the selected phase.
|
|
* selectedPhase undefined or ALL_PHASE -> all.
|
|
*/
|
|
export function filterAgentsByPhase(
|
|
agents: AgentProgress[],
|
|
selectedPhase: string | undefined,
|
|
): AgentProgress[] {
|
|
if (selectedPhase === undefined || selectedPhase === ALL_PHASE) return agents
|
|
return agents.filter(a => a.phase === selectedPhase)
|
|
}
|
|
|
|
/**
|
|
* Keep only runs still in flight. The /workflows panel defaults to this view: opening the panel
|
|
* no longer floods the tab row with months of persisted historical runs (which overflowed the
|
|
* terminal width and produced garbled overlapping text). Terminal runs (completed/failed/killed)
|
|
* stay on disk and remain resumable via getRunAsync; only the tab row filters them out.
|
|
*
|
|
* Pure + order-preserving: callers rely on the same relative order as the input (store.list()
|
|
* already returns newest-first by updatedAt).
|
|
*/
|
|
export function filterActiveRuns(runs: RunProgress[]): RunProgress[] {
|
|
return runs.filter(r => r.status === 'running')
|
|
}
|
|
|
|
/**
|
|
* Cap how many runs reach the tab row. Defensive fallback: even if active runs accumulate
|
|
* (long-lived session, runaway launcher), the row must never overflow the terminal width and
|
|
* re-introduce the garbled render. Anything past maxTabs is folded into an `overflow` count
|
|
* that the panel renders as `+N`.
|
|
*
|
|
* `runs` is sliced as-is (no re-sort); the caller is expected to have already applied
|
|
* filterActiveRuns and any ordering upstream.
|
|
*/
|
|
export function capTabsForDisplay(
|
|
runs: RunProgress[],
|
|
maxTabs: number,
|
|
): { runs: RunProgress[]; overflow: number } {
|
|
const cap = Math.max(0, Math.trunc(maxTabs))
|
|
const visible = runs.slice(0, cap)
|
|
return { runs: visible, overflow: Math.max(0, runs.length - visible.length) }
|
|
}
|
|
|
|
/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */
|
|
export function tabLabel(workflowName: string, runId: string): string {
|
|
return `${workflowName}#${runId.slice(-4)}`
|
|
}
|
|
|
|
/** milliseconds -> compact duration (<60s -> `Ns`; <60m -> `MmSSs`; otherwise `HhMMm`). Used by the panel header. */
|
|
export function formatDuration(ms: number): string {
|
|
const s = Math.floor(ms / 1000)
|
|
if (s < 60) return `${s}s`
|
|
const m = Math.floor(s / 60)
|
|
const ss = s % 60
|
|
if (m < 60) return `${m}m${String(ss).padStart(2, '0')}s`
|
|
const h = Math.floor(m / 60)
|
|
return `${h}h${String(m % 60).padStart(2, '0')}m`
|
|
}
|