API
{bootstrap?.harness.api_configured ? '已配置' : '未配置'}
@@ -946,7 +1042,13 @@ export default function App() {
return (
{timelineTitle(event)}
- {String(label)}
+
+ {event.event_type === 'model-route' ? (
+
+ ) : (
+ String(label)
+ )}
+
{event.payload.detail && {event.payload.detail}}
)
diff --git a/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.test.tsx b/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.test.tsx
new file mode 100644
index 0000000..5189c61
--- /dev/null
+++ b/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.test.tsx
@@ -0,0 +1,314 @@
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { api } from './api'
+import { ParallelPlanCard } from './ParallelPlanCard'
+import type { ModelRoute, ParallelPlan } from './types'
+
+vi.mock('./api', () => ({
+ api: { getParallelPlan: vi.fn(), decideParallelPlan: vi.fn(), cancelParallelPlan: vi.fn() },
+}))
+
+const plan: ParallelPlan = {
+ plan_id: 'parallel-one',
+ session_id: 'session-one',
+ reason: 'Review implementation and tests independently',
+ context: 'The operator has a boundary-case regression.\nDo not edit source or tests.',
+ user_request: 'Inspect implementation and test coverage before proposing a repair.',
+ tasks: [
+ { task_id: 'implementation', goal: 'Read the operator implementation' },
+ { task_id: 'tests', goal: 'Review existing test coverage' },
+ ],
+ status: 'pending',
+ model: 'review-model',
+ provider: 'test-provider',
+ base_url: 'https://provider.example.test/v1',
+ max_tool_calls: 8,
+ max_parallel: 2,
+ timeout_seconds: 120,
+ tools: ['read_file', 'search_code'],
+ results: [],
+ digest: 'digest-reviewed',
+}
+
+const fastRoute: ModelRoute = {
+ tier: 'fast',
+ model: 'fast-child-model',
+ provider: 'child-provider',
+ reason: 'Small read-only review',
+ timeout_seconds: 45,
+ input_bytes: 512,
+ policy_fingerprint: 'route-policy-one',
+}
+
+const mount = (onContinue = vi.fn().mockResolvedValue(undefined), disabled = false) => ({
+ onContinue,
+ ...render(
+
,
+ ),
+})
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(api.getParallelPlan).mockResolvedValue(plan)
+})
+afterEach(cleanup)
+
+describe('ParallelPlanCard', () => {
+ it('shows the actual plan and read-only scope without approving or executing on mount', async () => {
+ const { onContinue } = mount()
+ expect(await screen.findByText(plan.reason)).toBeInTheDocument()
+ for (const task of plan.tasks) expect(screen.getByText(task.goal)).toBeInTheDocument()
+ expect(screen.getByText(plan.model)).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('120 秒')).toBeInTheDocument()
+ expect(screen.getByText('只读工具范围')).toBeInTheDocument()
+ expect(screen.getByText('read_file, search_code')).toBeInTheDocument()
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('falls back to plan model and timeout for old plans without inventing route metadata', async () => {
+ const { onContinue } = mount()
+ await screen.findByRole('button', { name: '批准并继续' })
+ for (const task of screen.getAllByRole('listitem')) {
+ expect(task).toHaveTextContent(`模型:${plan.model}(未提供档位)`)
+ expect(task).toHaveTextContent(`超时:${plan.timeout_seconds} 秒`)
+ expect(task).toHaveTextContent('选择原因:旧计划未提供路由信息,沿用计划配置')
+ }
+ expect(screen.getByText('默认模型').parentElement).toHaveTextContent(plan.model)
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('shows authoritative per-child routes and Chinese tiers before approving the original digest', async () => {
+ const routes: ModelRoute[] = [
+ fastRoute,
+ {
+ ...fastRoute,
+ tier: 'strong',
+ model: 'strong-child-model',
+ reason: 'Complex implementation review',
+ timeout_seconds: 240,
+ },
+ {
+ ...fastRoute,
+ tier: 'default',
+ model: 'default-child-model',
+ reason: 'Routing disabled',
+ timeout_seconds: 90,
+ },
+ ]
+ const routedPlan: ParallelPlan = {
+ ...plan,
+ tasks: routes.map((route, index) => ({ task_id: `child-${index}`, goal: `Review ${index}`, route })),
+ digest: 'digest-with-routes',
+ }
+ vi.mocked(api.getParallelPlan).mockResolvedValue(routedPlan)
+ vi.mocked(api.decideParallelPlan).mockResolvedValue({ ...routedPlan, status: 'approved' })
+ const { onContinue } = mount()
+ const approve = await screen.findByRole('button', { name: '批准并继续' })
+ const tasks = screen.getAllByRole('listitem')
+ const tiers = ['快速', '强力', '默认']
+ routes.forEach((route, index) => {
+ expect(tasks[index]).toHaveTextContent(`模型:${route.model}(${tiers[index]})`)
+ expect(tasks[index]).toHaveTextContent(`提供方:${route.provider}`)
+ expect(tasks[index]).toHaveTextContent(`超时:${route.timeout_seconds} 秒`)
+ expect(tasks[index]).toHaveTextContent(`选择原因:${route.reason}`)
+ expect(tasks[index]).not.toHaveTextContent(plan.model)
+ expect(tasks[index]).not.toHaveTextContent(plan.provider!)
+ expect(tasks[index]).not.toHaveTextContent(`超时:${plan.timeout_seconds} 秒`)
+ expect(within(tasks[index]).getByText(/选择原因/)).toBeVisible()
+ })
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ expect(onContinue).not.toHaveBeenCalled()
+ fireEvent.click(approve)
+ await waitFor(() => expect(onContinue).toHaveBeenCalledExactlyOnceWith(plan.plan_id))
+ expect(api.decideParallelPlan).toHaveBeenCalledExactlyOnceWith(
+ plan.session_id,
+ plan.plan_id,
+ true,
+ routedPlan.digest,
+ )
+ })
+
+ it('falls back only for the legacy child in a mixed plan', async () => {
+ vi.mocked(api.getParallelPlan).mockResolvedValue({
+ ...plan,
+ tasks: [{ ...plan.tasks[0], route: fastRoute }, plan.tasks[1]],
+ })
+ mount()
+ await screen.findByRole('button', { name: '批准并继续' })
+ const [routed, legacy] = screen.getAllByRole('listitem')
+ expect(routed).toHaveTextContent(fastRoute.model)
+ expect(routed).not.toHaveTextContent('旧计划未提供路由信息')
+ expect(legacy).toHaveTextContent(plan.model)
+ expect(legacy).toHaveTextContent('旧计划未提供路由信息')
+ expect(legacy).not.toHaveTextContent(fastRoute.model)
+ })
+
+ it('approves the reviewed digest once, then requests continuation of the exact plan', async () => {
+ vi.mocked(api.decideParallelPlan).mockResolvedValue({ ...plan, status: 'approved' })
+ const { onContinue } = mount()
+ const approve = await screen.findByRole('button', { name: '批准并继续' })
+ fireEvent.click(approve)
+ fireEvent.click(approve)
+ await waitFor(() => expect(onContinue).toHaveBeenCalledExactlyOnceWith(plan.plan_id))
+ expect(api.decideParallelPlan).toHaveBeenCalledExactlyOnceWith(plan.session_id, plan.plan_id, true, plan.digest)
+ expect(vi.mocked(api.decideParallelPlan).mock.invocationCallOrder[0]).toBeLessThan(
+ onContinue.mock.invocationCallOrder[0],
+ )
+ expect(await screen.findByText('继续执行消息已发送,等待执行状态。')).toBeInTheDocument()
+ expect(screen.queryByText('已完成')).not.toBeInTheDocument()
+ })
+
+ it('expands the exact delegated background and original request without approving', async () => {
+ const { onContinue, container } = mount()
+ await screen.findByText(plan.reason)
+ const details = container.querySelector('.parallel-context')
+ expect(details).not.toHaveAttribute('open')
+ fireEvent.click(screen.getByText('委派内容与背景'))
+ expect(details).toHaveAttribute('open')
+ expect(screen.getByText(plan.user_request)).toBeVisible()
+ expect(container.querySelectorAll('.parallel-context pre')[1].textContent).toBe(plan.context)
+ expect(screen.getByText(plan.provider!)).toBeVisible()
+ expect(screen.getByText(plan.base_url!)).toBeVisible()
+ expect(screen.getByText('8')).toBeVisible()
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('rejects without continuing or cancelling', async () => {
+ vi.mocked(api.decideParallelPlan).mockResolvedValue({ ...plan, status: 'rejected' })
+ const { onContinue } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: '拒绝' }))
+ expect(await screen.findByText('已拒绝')).toBeInTheDocument()
+ expect(api.decideParallelPlan).toHaveBeenCalledWith(plan.session_id, plan.plan_id, false, plan.digest)
+ expect(onContinue).not.toHaveBeenCalled()
+ expect(api.cancelParallelPlan).not.toHaveBeenCalled()
+ expect(screen.queryByRole('button', { name: '批准并继续' })).not.toBeInTheDocument()
+ })
+
+ it('requires a refresh and explicit new approval after a wrong-digest error', async () => {
+ vi.mocked(api.decideParallelPlan).mockRejectedValueOnce(new Error('digest mismatch'))
+ const { onContinue } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: '批准并继续' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('digest mismatch')
+ expect(screen.queryByRole('button', { name: '批准并继续' })).not.toBeInTheDocument()
+ expect(onContinue).not.toHaveBeenCalled()
+ const updated = {
+ ...plan,
+ reason: 'Updated scope',
+ context: 'New delegated background',
+ user_request: 'Updated original request',
+ digest: 'digest-new',
+ }
+ vi.mocked(api.getParallelPlan).mockResolvedValue(updated)
+ fireEvent.click(screen.getByRole('button', { name: '刷新并行计划' }))
+ expect(await screen.findByText(updated.reason)).toBeInTheDocument()
+ fireEvent.click(screen.getByText('委派内容与背景'))
+ expect(screen.getByText(updated.context)).toBeVisible()
+ expect(screen.getByText(updated.user_request)).toBeVisible()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(api.decideParallelPlan).toHaveBeenCalledTimes(1)
+ vi.mocked(api.decideParallelPlan).mockResolvedValue({ ...updated, status: 'approved' })
+ fireEvent.click(screen.getByRole('button', { name: '批准并继续' }))
+ await waitFor(() => expect(onContinue).toHaveBeenCalledTimes(1))
+ expect(api.decideParallelPlan).toHaveBeenLastCalledWith(plan.session_id, plan.plan_id, true, updated.digest)
+ })
+
+ it.each([
+ { ...plan, status: 'approved' as const, digest: 'unexpected-digest' },
+ { ...plan, status: 'approved' as const, session_id: 'other-session' },
+ { ...plan, status: 'approved' as const, plan_id: 'other-plan' },
+ { ...plan, status: 'rejected' as const },
+ { ...plan, status: 'running' as const },
+ ])('does not continue an unexpected decision response: %j', async response => {
+ vi.mocked(api.decideParallelPlan).mockResolvedValue(response)
+ const { onContinue } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: '批准并继续' }))
+ expect(await screen.findByRole('alert')).toBeInTheDocument()
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('shows read failures and allows a refresh without any automatic decision', async () => {
+ vi.mocked(api.getParallelPlan).mockRejectedValueOnce(new Error('Plan unavailable'))
+ const { onContinue } = mount()
+ expect(await screen.findByRole('alert')).toHaveTextContent('Plan unavailable')
+ expect(screen.queryByRole('button', { name: '批准并继续' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '刷新并行计划' }))
+ await screen.findByRole('button', { name: '批准并继续' })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('keeps approval after a continuation error and retries only the parent message', async () => {
+ vi.mocked(api.decideParallelPlan).mockResolvedValue({ ...plan, status: 'approved' })
+ const onContinue = vi.fn().mockRejectedValueOnce(new Error('Message not delivered')).mockResolvedValue(undefined)
+ mount(onContinue)
+ fireEvent.click(await screen.findByRole('button', { name: '批准并继续' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Message not delivered')
+ expect(screen.queryByText('继续执行消息已发送,等待执行状态。')).not.toBeInTheDocument()
+ vi.mocked(api.getParallelPlan).mockResolvedValue({ ...plan, status: 'approved' })
+ fireEvent.click(screen.getByRole('button', { name: '继续已批准计划' }))
+ expect(await screen.findByText('继续执行消息已发送,等待执行状态。')).toBeInTheDocument()
+ expect(onContinue).toHaveBeenCalledTimes(2)
+ expect(api.decideParallelPlan).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('never auto-continues an already approved plan and rechecks its state before continuing', async () => {
+ vi.mocked(api.getParallelPlan)
+ .mockResolvedValueOnce({ ...plan, status: 'approved' })
+ .mockResolvedValue({ ...plan, status: 'cancelled' })
+ const { onContinue } = mount()
+ const button = await screen.findByRole('button', { name: '继续已批准计划' })
+ expect(onContinue).not.toHaveBeenCalled()
+ fireEvent.click(button)
+ expect(await screen.findByText('已取消')).toBeInTheDocument()
+ expect(onContinue).not.toHaveBeenCalled()
+ expect(api.decideParallelPlan).not.toHaveBeenCalled()
+ })
+
+ it('cancels a running plan and displays only the returned state', async () => {
+ vi.mocked(api.getParallelPlan).mockResolvedValue({ ...plan, status: 'running' })
+ vi.mocked(api.cancelParallelPlan).mockResolvedValue({ ...plan, status: 'cancelled' })
+ const { onContinue } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: '取消计划' }))
+ expect(await screen.findByText('已取消')).toBeInTheDocument()
+ expect(api.cancelParallelPlan).toHaveBeenCalledExactlyOnceWith(plan.session_id, plan.plan_id)
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('does not continue a decision that resolves after leaving the session', async () => {
+ let resolve!: (value: ParallelPlan) => void
+ vi.mocked(api.decideParallelPlan).mockReturnValue(
+ new Promise(done => {
+ resolve = done
+ }),
+ )
+ const { onContinue, unmount } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: '批准并继续' }))
+ unmount()
+ await act(async () => resolve({ ...plan, status: 'approved' }))
+ expect(onContinue).not.toHaveBeenCalled()
+ })
+
+ it('disables approval during parent activity but still allows rejection', async () => {
+ mount(undefined, true)
+ expect(await screen.findByRole('button', { name: '批准并继续' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '拒绝' })).toBeEnabled()
+ })
+
+ it.each(['partial', 'unknown', 'completed'] as const)(
+ 'renders %s without inventing successful results',
+ async status => {
+ vi.mocked(api.getParallelPlan).mockResolvedValue({ ...plan, status, results: [{ task_id: 'implementation' }] })
+ const { onContinue } = mount()
+ expect(await screen.findByText('已返回 1 条子任务结果')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '批准并继续' })).not.toBeInTheDocument()
+ expect(screen.queryByText('证据确认通过')).not.toBeInTheDocument()
+ expect(onContinue).not.toHaveBeenCalled()
+ },
+ )
+})
diff --git a/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.tsx b/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.tsx
new file mode 100644
index 0000000..5bbf42f
--- /dev/null
+++ b/packages/dsh-triton-riscv/frontend/src/ParallelPlanCard.tsx
@@ -0,0 +1,303 @@
+import { Check, Play, RefreshCw, Square, X } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { api } from './api'
+import { ModelRouteSummary } from './format'
+import type { ParallelPlan, ParallelPlanStatus } from './types'
+
+const statusLabels: Record
= {
+ pending: '等待审批',
+ approved: '已批准,等待执行',
+ rejected: '已拒绝',
+ running: '运行中',
+ completed: '已完成',
+ partial: '部分完成',
+ cancelled: '已取消',
+ unknown: '状态未知',
+}
+
+export function ParallelPlanCard({
+ sessionId,
+ planId,
+ summary,
+ disabled = false,
+ onContinue,
+}: {
+ sessionId: string
+ planId: string
+ summary?: string
+ disabled?: boolean
+ onContinue: (planId: string) => Promise
+}) {
+ const [plan, setPlan] = useState(null)
+ const [pending, setPending] = useState(true)
+ const [continued, setContinued] = useState(false)
+ const [error, setError] = useState(null)
+ const revision = useRef(0)
+ const locked = useRef(false)
+
+ const checked = (item: ParallelPlan) => {
+ if (item.session_id !== sessionId || item.plan_id !== planId || !item.digest) {
+ throw new Error('计划标识或摘要不匹配,请刷新后重新审查。')
+ }
+ return item
+ }
+
+ const refresh = async () => {
+ if (locked.current) return
+ locked.current = true
+ const current = ++revision.current
+ setPending(true)
+ setError(null)
+ try {
+ const item = checked(await api.getParallelPlan(sessionId, planId))
+ if (current === revision.current) setPlan(item)
+ } catch (reason) {
+ if (current === revision.current) {
+ setPlan(null)
+ setError(reason instanceof Error ? reason.message : '读取并行计划失败')
+ }
+ } finally {
+ if (current === revision.current) {
+ locked.current = false
+ setPending(false)
+ }
+ }
+ }
+
+ useEffect(() => {
+ locked.current = false
+ setPlan(null)
+ setContinued(false)
+ void refresh()
+ return () => {
+ revision.current += 1
+ }
+ }, [sessionId, planId])
+
+ useEffect(() => {
+ if (pending || !(plan?.status === 'running' || (plan?.status === 'approved' && continued))) return
+ const timer = window.setTimeout(() => void refresh(), 3000)
+ return () => window.clearTimeout(timer)
+ }, [plan, pending, continued])
+
+ const decide = async (approved: boolean) => {
+ if (!plan || plan.status !== 'pending' || locked.current || (approved && disabled)) return
+ locked.current = true
+ const current = ++revision.current
+ setPending(true)
+ setError(null)
+ let decisionRecorded = false
+ try {
+ const updated = checked(await api.decideParallelPlan(sessionId, planId, approved, plan.digest))
+ if (current !== revision.current) return
+ if (updated.digest !== plan.digest) throw new Error('计划摘要已变更,请刷新后重新审查。')
+ setPlan(updated)
+ decisionRecorded = true
+ if (approved && updated.status === 'approved') {
+ await onContinue(planId)
+ if (current === revision.current) setContinued(true)
+ } else if (updated.status !== (approved ? 'approved' : 'rejected')) {
+ setError('计划状态已变更,未发送执行请求。')
+ }
+ } catch (reason) {
+ if (current === revision.current) {
+ // A failed decision may be stale or already recorded; never retry it blindly.
+ if (!decisionRecorded) setPlan(null)
+ setError(reason instanceof Error ? reason.message : '提交并行计划审批失败')
+ }
+ } finally {
+ if (current === revision.current) {
+ locked.current = false
+ setPending(false)
+ }
+ }
+ }
+
+ const continueApproved = async () => {
+ if (!plan || plan.status !== 'approved' || locked.current || disabled || continued) return
+ locked.current = true
+ const current = ++revision.current
+ setPending(true)
+ setError(null)
+ try {
+ const updated = checked(await api.getParallelPlan(sessionId, planId))
+ if (current !== revision.current) return
+ setPlan(updated)
+ if (updated.status !== 'approved' || updated.digest !== plan.digest) {
+ setError('计划状态或摘要已变更,未发送执行请求。')
+ return
+ }
+ await onContinue(planId)
+ if (current === revision.current) setContinued(true)
+ } catch (reason) {
+ if (current === revision.current) setError(reason instanceof Error ? reason.message : '发送执行请求失败')
+ } finally {
+ if (current === revision.current) {
+ locked.current = false
+ setPending(false)
+ }
+ }
+ }
+
+ const cancel = async () => {
+ if (!plan || !['approved', 'running'].includes(plan.status) || locked.current) return
+ locked.current = true
+ const current = ++revision.current
+ setPending(true)
+ setError(null)
+ try {
+ const updated = checked(await api.cancelParallelPlan(sessionId, planId))
+ if (current === revision.current) setPlan(updated)
+ } catch (reason) {
+ if (current === revision.current) {
+ setPlan(null)
+ setError(reason instanceof Error ? reason.message : '取消并行计划失败')
+ }
+ } finally {
+ if (current === revision.current) {
+ locked.current = false
+ setPending(false)
+ }
+ }
+ }
+
+ return (
+
+
+
并行子任务计划
+
+ {plan ? statusLabels[plan.status] || statusLabels.unknown : pending ? '加载中' : '待刷新'}
+
+
+
+ {planId}
+ {plan?.reason || summary}
+ {plan && (
+ <>
+
+ {plan.tasks.map(task => (
+ -
+
{task.task_id}
+ {task.goal}
+
+
+
+
+ ))}
+
+
+
+
- 默认模型
+ - {plan.model}
+
+
+
- 最大并发
+ - {plan.max_parallel}
+
+
+
- 单个子任务最长时限
+ - {plan.timeout_seconds} 秒
+
+
+
- 只读工具范围
+ - {plan.tools.join(', ') || '无工具'}
+
+
+
+ 委派内容与背景
+
+ - 原始用户请求
+ -
+
{plan.user_request || '未提供原始用户请求'}
+
+ - 委派背景
+ -
+
{plan.context || '无额外背景'}
+
+ {plan.provider && (
+ <>
+ - 父级模型提供方
+ - {plan.provider}
+ >
+ )}
+ {plan.base_url && (
+ <>
+ - 父级模型端点
+ - {plan.base_url}
+ >
+ )}
+ {plan.max_tool_calls !== undefined && (
+ <>
+ - 工具调用上限
+ - {plan.max_tool_calls}
+ >
+ )}
+
+
+ {plan.results.length > 0 && 已返回 {plan.results.length} 条子任务结果
}
+ {plan.status === 'approved' && continued && 继续执行消息已发送,等待执行状态。
}
+
+ {plan.status === 'pending' && (
+ <>
+
+
+ >
+ )}
+ {plan.status === 'approved' && !continued && (
+
+ )}
+ {['approved', 'running'].includes(plan.status) && (
+
+ )}
+
+ >
+ )}
+ {error && (
+
+ {error} 请刷新计划确认最新状态。
+
+ )}
+
+ )
+}
diff --git a/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.parallel.test.tsx b/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.parallel.test.tsx
new file mode 100644
index 0000000..274a73d
--- /dev/null
+++ b/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.parallel.test.tsx
@@ -0,0 +1,554 @@
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { api } from './api'
+import { WorkflowPanel } from './WorkflowPanel'
+import { workflowApi, type Workflow, type WorkflowParallelPlanView } from './workflow-api'
+
+vi.mock('./workflow-api', () => ({
+ workflowApi: { list: vi.fn(), get: vi.fn(), submit: vi.fn(), action: vi.fn(), decideParallelPlan: vi.fn() },
+}))
+vi.mock('./api', () => ({
+ api: {
+ decideProposal: vi.fn(),
+ decideValidationPlan: vi.fn(),
+ getParallelPlan: vi.fn(),
+ decideParallelPlan: vi.fn(),
+ cancelParallelPlan: vi.fn(),
+ sendMessage: vi.fn(),
+ },
+}))
+
+const plan: WorkflowParallelPlanView = {
+ plan_id: 'parallel-one',
+ session_id: 'session-one',
+ workflow_id: 'workflow-one',
+ digest: 'reviewed-digest',
+ status: 'pending',
+ reason: 'Independent read-only implementation and test reviews',
+ context: 'Do not edit code or execute validation.',
+ user_request: 'Review the square operator.',
+ tasks: [
+ {
+ task_id: 'implementation',
+ goal: 'Inspect operator source',
+ route: {
+ model: 'child-model',
+ provider: 'child-provider',
+ tier: 'strong',
+ reason: 'code_or_review_role',
+ timeout_seconds: 90,
+ input_bytes: 512,
+ policy_fingerprint: 'policy-one',
+ },
+ },
+ { task_id: 'tests', goal: 'Inspect coverage' },
+ ],
+ model: 'parent-model',
+ provider: 'parent-provider',
+ max_parallel: 2,
+ timeout_seconds: 120,
+ max_tool_calls: 8,
+ tools: ['read_file', 'search_code'],
+ results: [],
+}
+
+const task = (extra: Partial = {}): Workflow => ({
+ id: 'workflow-one',
+ session_id: 'session-one',
+ operator: 'square_new',
+ kind: 'create',
+ state: 'waiting_parallel_approval',
+ revision: 7,
+ repair_count: 0,
+ max_repairs: 3,
+ recovery_action: '',
+ request: { requirement: 'Review the square operator.' },
+ result: {},
+ steps: [],
+ parallel_plan: plan,
+ ...extra,
+})
+
+function mount(value = task(), enabled = true) {
+ vi.mocked(workflowApi.list).mockResolvedValue([value])
+ vi.mocked(workflowApi.get).mockResolvedValue(value)
+ return render()
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+function expectNoOtherActions() {
+ expect(workflowApi.action).not.toHaveBeenCalled()
+ expect(workflowApi.submit).not.toHaveBeenCalled()
+ for (const method of Object.values(api)) expect(method).not.toHaveBeenCalled()
+}
+
+beforeEach(() => vi.resetAllMocks())
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe('optional workflow parallel approval', () => {
+ it('puts pending decisions before long requirements, history and plan details without auto-approving', async () => {
+ mount(task({ request: { requirement: 'Long operator specification.\n'.repeat(200) } }))
+ const region = await screen.findByRole('region', { name: '工作流并行计划' })
+ const requirement = screen.getByText('原始需求').closest('details')!
+ expect(requirement.open).toBe(false)
+ expect(region.compareDocumentPosition(requirement) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ const approve = within(region).getByRole('button', { name: '批准只读并行' })
+ expect(approve.compareDocumentPosition(within(region).getByText(plan.reason)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ expect(approve).toBeEnabled()
+ expect(within(region).getByRole('button', { name: '不并行,继续处理' })).toBeEnabled()
+ fireEvent.click(screen.getByText('原始需求'))
+ expect(requirement.open).toBe(true)
+ expect(requirement).toHaveTextContent('Long operator specification.')
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ expectNoOtherActions()
+ })
+
+ it('shows the reviewed scope, real child routes and bounded read-only limits without taking action', async () => {
+ mount()
+ const region = await screen.findByRole('region', { name: '工作流并行计划' })
+ expect(within(region).getByText('等待并行审批')).toBeVisible()
+ expect(within(region).getByText(plan.reason)).toBeVisible()
+ const children = within(region).getAllByRole('listitem')
+ expect(children[0]).toHaveTextContent('Inspect operator source')
+ expect(children[0]).toHaveTextContent('模型:child-model(强力)')
+ expect(children[0]).toHaveTextContent('提供方:child-provider')
+ expect(children[0]).toHaveTextContent('超时:90 秒')
+ expect(children[1]).toHaveTextContent('模型:parent-model(未提供档位)')
+ expect(children[1]).toHaveTextContent('超时:120 秒')
+ expect(within(region).getByText('默认模型').parentElement).toHaveTextContent(plan.model)
+ expect(within(region).queryByText('父级模型(旧版默认)')).not.toBeInTheDocument()
+ expect(within(region).getByText('最大并发').parentElement).toHaveTextContent('2')
+ expect(within(region).getByText('工具调用上限').parentElement).toHaveTextContent('8')
+ expect(within(region).getByText('只读工具范围').parentElement).toHaveTextContent('read_file, search_code')
+ fireEvent.click(within(region).getByText('委派内容与背景'))
+ expect(within(region).getByText(plan.context)).toBeVisible()
+ expect(within(region).getByText(plan.user_request)).toBeVisible()
+ expect(screen.queryByLabelText('审查人')).not.toBeInTheDocument()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ expectNoOtherActions()
+ })
+
+ it.each([true, false])(
+ 'records approved=%s once, disables controls pending HTTP, and uses the returned workflow',
+ async approved => {
+ mount()
+ const decision = deferred()
+ vi.mocked(workflowApi.decideParallelPlan).mockReturnValue(decision.promise)
+ const button = await screen.findByRole('button', {
+ name: approved ? '批准只读并行' : '不并行,继续处理',
+ })
+ fireEvent.click(button)
+ fireEvent.click(button)
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '刷新工作流并行计划' })).toBeDisabled()
+ expect(screen.getByLabelText('已保存任务')).toBeDisabled()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledExactlyOnceWith(task(), approved)
+
+ await act(async () =>
+ decision.resolve(
+ task({
+ revision: 8,
+ state: 'preparing',
+ parallel_plan: { ...plan, status: approved ? 'approved' : 'rejected' },
+ }),
+ ),
+ )
+ expect(screen.getByText(approved ? '已批准并行,等待执行' : '已选择不并行,继续顺序处理')).toBeVisible()
+ expect(screen.queryByRole('button', { name: '批准只读并行' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '不并行,继续处理' })).not.toBeInTheDocument()
+ expect(workflowApi.get).toHaveBeenCalledTimes(1)
+ expectNoOtherActions()
+ },
+ )
+
+ it.each([
+ [true, 'stale workflow revision'],
+ [false, 'stale workflow revision'],
+ [true, 'digest mismatch'],
+ [false, 'digest mismatch'],
+ ] as const)(
+ 'requires refresh and explicit new consent after approved=%s fails: %s',
+ async (approved, message) => {
+ mount()
+ vi.mocked(workflowApi.decideParallelPlan).mockRejectedValueOnce(new Error(message))
+ fireEvent.click(
+ await screen.findByRole('button', { name: approved ? '批准只读并行' : '不并行,继续处理' }),
+ )
+ expect(await screen.findByRole('alert')).toHaveTextContent(message)
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ const refreshed = task({
+ revision: 10,
+ parallel_plan: { ...plan, reason: 'Revised scope', digest: 'new-digest' },
+ })
+ vi.mocked(workflowApi.get).mockResolvedValue(refreshed)
+ fireEvent.click(screen.getByRole('button', { name: '刷新工作流并行计划' }))
+ expect(await screen.findByText('Revised scope')).toBeVisible()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledTimes(1)
+ vi.mocked(workflowApi.decideParallelPlan).mockResolvedValue({
+ ...refreshed,
+ revision: 11,
+ state: 'preparing',
+ parallel_plan: { ...refreshed.parallel_plan!, status: 'rejected' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: '不并行,继续处理' }))
+ await screen.findByText('已选择不并行,继续顺序处理')
+ expect(workflowApi.decideParallelPlan).toHaveBeenLastCalledWith(refreshed, false)
+ expectNoOtherActions()
+ },
+ )
+
+ it('keeps decisions disabled if the required refresh fails', async () => {
+ mount()
+ vi.mocked(workflowApi.decideParallelPlan).mockRejectedValueOnce(new Error('digest mismatch'))
+ fireEvent.click(await screen.findByRole('button', { name: '批准只读并行' }))
+ await screen.findByRole('alert')
+ vi.mocked(workflowApi.get).mockRejectedValue(new Error('offline'))
+ fireEvent.click(screen.getByRole('button', { name: '刷新工作流并行计划' }))
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('offline'))
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledTimes(1)
+ })
+
+ it('coalesces repeated refresh clicks and keeps decisions locked until the latest view is read', async () => {
+ vi.useFakeTimers()
+ mount()
+ await act(async () => {})
+ const refreshed = task({
+ revision: 8,
+ parallel_plan: { ...plan, reason: 'Refreshed scope', digest: 'new-digest' },
+ })
+ const refresh = deferred()
+ vi.mocked(workflowApi.get).mockReturnValueOnce(refresh.promise)
+ const button = screen.getByRole('button', { name: '刷新工作流并行计划' })
+ fireEvent.click(button)
+ fireEvent.click(button)
+ expect(button).toBeDisabled()
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ expect(workflowApi.get).toHaveBeenCalledTimes(2)
+ expect(workflowApi.list).toHaveBeenCalledTimes(1)
+
+ await act(async () => refresh.resolve(refreshed))
+ expect(screen.getByText('Refreshed scope')).toBeVisible()
+ expect(button).toBeEnabled()
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeEnabled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+
+ const second = deferred()
+ vi.mocked(workflowApi.get).mockReturnValueOnce(second.promise)
+ fireEvent.click(button)
+ fireEvent.click(button)
+ expect(workflowApi.get).toHaveBeenCalledTimes(3)
+ await act(async () => second.resolve({ ...refreshed, revision: 9 }))
+ expect(button).toBeEnabled()
+ expectNoOtherActions()
+ })
+
+ it.each(['pending', 'approved', 'running'] as const)(
+ 'shows workflow fallback over the underlying %s plan without offering parallel decisions',
+ async status => {
+ mount(
+ task({
+ parallel_plan: {
+ ...plan,
+ status,
+ workflow_status: 'fallback',
+ fallback_reason: '计划已过期或模型配置已变更',
+ },
+ }),
+ )
+ const region = await screen.findByRole('region', { name: '工作流并行计划' })
+ expect(within(region).getByText('已回退为顺序处理')).toBeVisible()
+ expect(within(region).getByRole('status')).toHaveTextContent('并行回退原因:计划已过期或模型配置已变更')
+ expect(within(region).queryByText('等待并行审批')).not.toBeInTheDocument()
+ expect(within(region).queryByText('已批准并行,等待执行')).not.toBeInTheDocument()
+ expect(within(region).queryByText('并行运行中')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '批准只读并行' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '不并行,继续处理' })).not.toBeInTheDocument()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ expectNoOtherActions()
+ },
+ )
+
+ it('shows fallback returned directly by approval instead of claiming it is queued', async () => {
+ mount()
+ vi.mocked(workflowApi.decideParallelPlan).mockResolvedValue(
+ task({
+ revision: 8,
+ state: 'preparing',
+ parallel_plan: {
+ ...plan,
+ status: 'approved',
+ workflow_status: 'fallback',
+ fallback_reason: 'parallel configuration changed',
+ },
+ }),
+ )
+ fireEvent.click(await screen.findByRole('button', { name: '批准只读并行' }))
+ expect(await screen.findByText('已回退为顺序处理')).toBeVisible()
+ expect(screen.getByRole('status')).toHaveTextContent('parallel configuration changed')
+ expect(screen.queryByText('已批准并行,等待执行')).not.toBeInTheDocument()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledTimes(1)
+ expectNoOtherActions()
+ })
+
+ it('does not invent a fallback reason when the server omits it', async () => {
+ mount(task({ parallel_plan: { ...plan, status: 'running', workflow_status: 'fallback' } }))
+ expect(await screen.findByText('已回退为顺序处理')).toBeVisible()
+ expect(screen.getByRole('status')).toHaveTextContent('并行回退原因:未提供原因')
+ expect(screen.queryByText('并行运行中')).not.toBeInTheDocument()
+ })
+
+ it('accepts legacy views without an optional workflow binding', async () => {
+ mount(task({ parallel_plan: { ...plan, workflow_id: undefined } }))
+ expect(await screen.findByRole('button', { name: '批准只读并行' })).toBeEnabled()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it.each([{ id: 'other-workflow' }, { session_id: 'other-session' }, { revision: 6 }])(
+ 'does not replace the reviewed workflow with an inconsistent response: %j',
+ async extra => {
+ mount()
+ vi.mocked(workflowApi.decideParallelPlan).mockResolvedValue(task(extra))
+ fireEvent.click(await screen.findByRole('button', { name: '批准只读并行' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('任务标识或版本不匹配')
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByText('workflow-one')).toBeVisible()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledTimes(1)
+ expectNoOtherActions()
+ },
+ )
+
+ it('keeps code approval separate from completed parallel reviews', async () => {
+ mount(
+ task({
+ state: 'waiting_approval',
+ parallel_plan: { ...plan, status: 'completed' },
+ proposal: {
+ proposal_id: 'proposal-one',
+ operator: 'square_new',
+ status: 'pending_approval',
+ rationale: 'Review code',
+ diff: '+ return x*x',
+ },
+ }),
+ )
+ expect(await screen.findByRole('button', { name: '批准并应用' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('审查人'), { target: { value: 'reviewer' } })
+ vi.mocked(workflowApi.action).mockResolvedValue(
+ task({ revision: 8, state: 'waiting_validation_approval' }),
+ )
+ fireEvent.click(screen.getByRole('button', { name: '批准并应用' }))
+ await waitFor(() => expect(workflowApi.action).toHaveBeenCalledTimes(2))
+ expect(api.decideProposal).toHaveBeenCalledExactlyOnceWith(
+ 'development',
+ 'proposal-one',
+ true,
+ 'reviewer',
+ )
+ expect(vi.mocked(workflowApi.action).mock.calls.map(call => call[1])).toEqual([
+ 'apply',
+ 'plan-validation',
+ ])
+ expect(api.decideValidationPlan).not.toHaveBeenCalled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ })
+
+ it('does not approve validation as a side effect of a historical parallel decision', async () => {
+ mount(
+ task({
+ state: 'waiting_validation_approval',
+ parallel_plan: { ...plan, status: 'rejected' },
+ validation_plan: {
+ run_id: 'validation-one',
+ operator: 'square_new',
+ status: 'planned',
+ command: 'pytest fixed_test.py',
+ execution_target: 'remote',
+ receipt_path: 'receipt.json',
+ approval: { status: 'pending_approval' },
+ },
+ }),
+ )
+ expect(await screen.findByRole('button', { name: '批准并执行' })).toBeDisabled()
+ expect(api.decideValidationPlan).not.toHaveBeenCalled()
+ fireEvent.change(screen.getByLabelText('审查人'), { target: { value: 'reviewer' } })
+ fireEvent.click(screen.getByRole('button', { name: '批准并执行' }))
+ await waitFor(() => expect(workflowApi.action).toHaveBeenCalledTimes(1))
+ expect(api.decideValidationPlan).toHaveBeenCalledExactlyOnceWith('validation-one', true, 'reviewer')
+ expect(workflowApi.action).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'workflow-one' }),
+ 'enqueue-validation',
+ )
+ expect(api.decideProposal).not.toHaveBeenCalled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ })
+
+ it.each(['approved', 'running', 'completed', 'partial', 'rejected', 'cancelled', 'unknown'] as const)(
+ 'shows %s and available results without sending any continuation',
+ async status => {
+ mount(
+ task({
+ state: 'preparing',
+ parallel_plan: { ...plan, status, results: [{ task_id: 'tests', summary: 'Coverage inspected' }] },
+ }),
+ )
+ const region = await screen.findByRole('region', { name: '工作流并行计划' })
+ const labels = {
+ approved: '已批准并行,等待执行',
+ running: '并行运行中',
+ completed: '并行子任务已完成',
+ partial: '并行子任务部分完成',
+ rejected: '已选择不并行,继续顺序处理',
+ cancelled: '并行已取消',
+ unknown: '并行状态未知',
+ }
+ expect(within(region).getByText(labels[status])).toBeVisible()
+ fireEvent.click(within(region).getByText('已返回 1 条子任务结果'))
+ expect(within(region).getByText(/Coverage inspected/)).toBeVisible()
+ expect(screen.queryByRole('button', { name: '批准只读并行' })).not.toBeInTheDocument()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ expectNoOtherActions()
+ },
+ )
+
+ it.each([null, undefined])(
+ 'keeps workflows with parallel_plan=%s on the original role path',
+ async parallel_plan => {
+ mount(
+ task({
+ state: 'preparing',
+ parallel_plan,
+ steps: [
+ {
+ id: 'semantic',
+ role: 'semantic',
+ state: 'preparing',
+ status: 'completed',
+ job_id: 'job-one',
+ result: { summary: 'Requirements checked' },
+ },
+ ],
+ }),
+ )
+ expect(await screen.findByText('语义 Agent · 确认需求')).toBeVisible()
+ expect(screen.queryByRole('region', { name: '工作流并行计划' })).not.toBeInTheDocument()
+ expectNoOtherActions()
+ },
+ )
+
+ it('requires a loaded plan before offering decisions', async () => {
+ mount(task({ parallel_plan: null }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('并行计划尚未加载')
+ expect(screen.queryByRole('button', { name: '批准只读并行' })).not.toBeInTheDocument()
+ vi.mocked(workflowApi.get).mockResolvedValue(task())
+ fireEvent.click(screen.getByRole('button', { name: '刷新工作流并行计划' }))
+ expect(await screen.findByRole('button', { name: '批准只读并行' })).toBeEnabled()
+ })
+
+ it.each([
+ { ...plan, digest: '' },
+ { ...plan, plan_id: '' },
+ { ...plan, session_id: 'other-session' },
+ { ...plan, workflow_id: 'other-workflow' },
+ ])('blocks a plan with invalid approval identity: %j', async parallel_plan => {
+ mount(task({ parallel_plan }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('并行计划标识或摘要不匹配')
+ expect(screen.getByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ })
+
+ it.each([false, true])(
+ 'honors disabled installations and existing artifact blocks (enabled=%s)',
+ async enabled => {
+ mount(task(enabled ? { artifact_error: 'Missing evidence' } : {}), enabled)
+ expect(await screen.findByRole('button', { name: '批准只读并行' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '不并行,继续处理' })).toBeDisabled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ },
+ )
+
+ it('does not let an old poll restore an approved plan after repeated refresh returns fallback', async () => {
+ vi.useFakeTimers()
+ const approved = task({ state: 'preparing', parallel_plan: { ...plan, status: 'approved' } })
+ mount(approved)
+ await act(async () => {})
+ const oldPoll = deferred()
+ const refresh = deferred()
+ vi.mocked(workflowApi.get).mockReturnValueOnce(oldPoll.promise).mockReturnValueOnce(refresh.promise)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ const button = screen.getByRole('button', { name: '刷新工作流并行计划' })
+ fireEvent.click(button)
+ fireEvent.click(button)
+ expect(workflowApi.get).toHaveBeenCalledTimes(3)
+ await act(async () =>
+ refresh.resolve(
+ task({
+ revision: 8,
+ state: 'preparing',
+ parallel_plan: {
+ ...plan,
+ status: 'approved',
+ workflow_status: 'fallback',
+ fallback_reason: 'plan expired',
+ },
+ }),
+ ),
+ )
+ await act(async () => oldPoll.resolve(approved))
+ expect(screen.getByText('已回退为顺序处理')).toBeVisible()
+ expect(screen.getByRole('status')).toHaveTextContent('plan expired')
+ expect(screen.queryByText('已批准并行,等待执行')).not.toBeInTheDocument()
+ expect(button).toBeEnabled()
+ expect(workflowApi.decideParallelPlan).not.toHaveBeenCalled()
+ expectNoOtherActions()
+ })
+
+ it('discards an old in-flight poll after a decision and polls the later completed outcome', async () => {
+ vi.useFakeTimers()
+ mount()
+ await act(async () => {})
+ const oldPoll = deferred()
+ vi.mocked(workflowApi.get).mockReturnValueOnce(oldPoll.promise)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ expect(workflowApi.get).toHaveBeenCalledTimes(2)
+ vi.mocked(workflowApi.decideParallelPlan).mockResolvedValue(
+ task({ revision: 8, state: 'preparing', parallel_plan: { ...plan, status: 'running' } }),
+ )
+ await act(async () => fireEvent.click(screen.getByRole('button', { name: '批准只读并行' })))
+ await act(async () => oldPoll.resolve(task()))
+ expect(screen.getByText('并行运行中')).toBeVisible()
+ expect(screen.queryByRole('button', { name: '批准只读并行' })).not.toBeInTheDocument()
+ vi.mocked(workflowApi.get).mockResolvedValue(
+ task({ revision: 9, state: 'preparing', parallel_plan: { ...plan, status: 'completed' } }),
+ )
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ expect(screen.getByText('并行子任务已完成')).toBeVisible()
+ expect(workflowApi.decideParallelPlan).toHaveBeenCalledTimes(1)
+ expectNoOtherActions()
+ })
+})
diff --git a/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.tsx b/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.tsx
index a0600a6..ef3a042 100644
--- a/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.tsx
+++ b/packages/dsh-triton-riscv/frontend/src/WorkflowPanel.tsx
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { api } from './api'
import { accountName } from './auth'
import { MessageText } from './format'
+import { WorkflowParallelPlan } from './WorkflowParallelPlan'
import { workflowApi, type Workflow, type WorkflowSubmission } from './workflow-api'
const phases: Record = {
@@ -10,6 +11,7 @@ const phases: Record = {
planning_tests: '设计验收测试',
implementing: '提出实现',
reviewing: '独立审查',
+ waiting_parallel_approval: '等待并行审批',
waiting_approval: '等待方案审批',
applying: '写入代码',
waiting_validation_approval: '等待验证审批',
@@ -42,23 +44,40 @@ export function WorkflowPanel({ sessionId, enabled }: { sessionId: string; enabl
const [error, setError] = useState('')
const pending = useRef<{ fingerprint: string; body: WorkflowSubmission } | null>(null)
const generation = useRef(0)
+ const parallelRequest = useRef(false)
+ const parallelReadVersion = useRef(0)
+ const content = useRef(null)
+ const [parallelNeedsRefresh, setParallelNeedsRefresh] = useState(null)
+
+ useEffect(() => {
+ if (task?.state === 'waiting_parallel_approval' && content.current) content.current.scrollTop = 0
+ }, [task?.id, task?.state])
useEffect(() => {
const current = ++generation.current
let timer: ReturnType
let active = true
const refresh = async () => {
+ const readVersion = parallelReadVersion.current
+ const isCurrent = () =>
+ active && current === generation.current &&
+ !parallelRequest.current && readVersion === parallelReadVersion.current
try {
+ if (parallelRequest.current) return
const items = await workflowApi.list(sessionId)
- if (!active) return
+ if (!isCurrent()) return
setRows(items)
const id = selected || items[0]?.id
if (id) {
const detail = await workflowApi.get(id)
- if (active && current === generation.current) setTask(detail)
+ if (isCurrent()) {
+ setTask(previous =>
+ previous?.id === detail.id && previous.revision > detail.revision ? previous : detail,
+ )
+ }
} else setTask(null)
} catch (reason) {
- if (active) setError(reason instanceof Error ? reason.message : '读取任务失败')
+ if (isCurrent()) setError(reason instanceof Error ? reason.message : '读取任务失败')
} finally {
if (active) timer = setTimeout(() => void refresh(), 2000)
}
@@ -66,6 +85,7 @@ export function WorkflowPanel({ sessionId, enabled }: { sessionId: string; enabl
void refresh()
return () => {
active = false
+ generation.current += 1
clearTimeout(timer)
}
}, [sessionId, selected])
@@ -116,6 +136,51 @@ export function WorkflowPanel({ sessionId, enabled }: { sessionId: string; enabl
)
const authorized = reviewer.trim().length > 0 && !busy && !blocked
const command = (name: string) => task && act(() => workflowApi.action(task, name))
+ const parallelPlan = task?.parallel_plan
+ const waitingParallel = task?.state === 'waiting_parallel_approval'
+ const parallelInvalid = Boolean(
+ parallelPlan && (
+ parallelPlan.session_id !== sessionId || !parallelPlan.plan_id || !parallelPlan.digest ||
+ (parallelPlan.workflow_id !== undefined && parallelPlan.workflow_id !== task?.id)
+ ),
+ )
+
+ const updateParallel = async (approved?: boolean) => {
+ if (!task || busy || parallelRequest.current) return
+ if (
+ approved !== undefined && (
+ !enabled || blocked || parallelInvalid || parallelNeedsRefresh === task.id ||
+ task.state !== 'waiting_parallel_approval' || parallelPlan?.status !== 'pending' ||
+ parallelPlan.workflow_status === 'fallback'
+ )
+ ) return
+ parallelRequest.current = true
+ // Discard polls begun before or during this decision, including on HTTP failure.
+ parallelReadVersion.current += 1
+ const current = generation.current
+ setBusy(true)
+ setError('')
+ try {
+ const updated = approved === undefined
+ ? await workflowApi.get(task.id)
+ : await workflowApi.decideParallelPlan(task, approved)
+ if (current !== generation.current) return
+ if (updated.id !== task.id || updated.session_id !== sessionId || updated.revision < task.revision) {
+ throw new Error('任务标识或版本不匹配。')
+ }
+ setTask(updated)
+ setRows(items => items.map(item => item.id === updated.id ? updated : item))
+ setParallelNeedsRefresh(null)
+ } catch (reason) {
+ if (current !== generation.current) return
+ setParallelNeedsRefresh(task.id)
+ setError(`${reason instanceof Error ? reason.message : '提交并行决策失败'} 请刷新工作流并行计划后重新审查。`)
+ } finally {
+ parallelReadVersion.current += 1
+ parallelRequest.current = false
+ setBusy(false)
+ }
+ }
const decide = async (approve: boolean) => {
if (!task) return
@@ -169,13 +234,24 @@ export function WorkflowPanel({ sessionId, enabled }: { sessionId: string; enabl
)}
-