Compare commits

...

1 Commits

Author SHA1 Message Date
bigcat88
0cb19adde3 Support partial graph execution 2026-07-13 12:39:33 +03:00
24 changed files with 482 additions and 19 deletions

View File

@@ -8,6 +8,7 @@ import type {
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useCommandStore } from '@/stores/commandStore'
import { api } from '@/scripts/api'
import {
TaskItemImpl,
useQueueSettingsStore,
@@ -53,6 +54,8 @@ const i18n = createI18n({
en: {
menu: {
run: 'Run',
continueIndependentBranches:
'Continue independent branches if a node fails',
disabledTooltip: 'Disabled tooltip',
onChange: 'On Change',
onChangeTooltip: 'On change tooltip',
@@ -85,7 +88,9 @@ const stubs = {
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
DropdownMenuItem: { template: '<div><slot /></div>' },
DropdownMenuCheckboxItem: { template: '<div><slot /></div>' },
DropdownMenuItemIndicator: { template: '<span><slot /></span>' }
}
function renderQueueButton() {
@@ -106,6 +111,19 @@ function renderQueueButton() {
}
describe('ComfyQueueButton', () => {
it('only offers continuing independent branches when supported', async () => {
renderQueueButton()
expect(
screen.queryByTestId('continue-independent-branches')
).not.toBeInTheDocument()
api.serverFeatureFlags.value = { supports_node_failure_policy: true }
await nextTick()
expect(
screen.getByTestId('continue-independent-branches')
).toHaveTextContent('Continue independent branches if a node fails')
})
it('renders the batch count control before the run button', () => {
renderQueueButton()
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)

View File

@@ -56,6 +56,20 @@
{{ item.label }}
</Button>
</DropdownMenuItem>
<DropdownMenuCheckboxItem
v-if="supportsNodeFailurePolicy"
v-model:checked="continueIndependentBranches"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary outline-none hover:bg-secondary-background-hover"
data-testid="continue-independent-branches"
@select.prevent
>
<span class="flex size-4 items-center justify-center">
<DropdownMenuItemIndicator>
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</span>
{{ t('menu.continueIndependentBranches') }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
@@ -64,8 +78,10 @@
<script setup lang="ts">
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
@@ -78,6 +94,7 @@ import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -93,7 +110,15 @@ import { cn } from '@comfyorg/tailwind-utils'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const {
mode: queueMode,
batchCount,
continueIndependentBranches
} = storeToRefs(useQueueSettingsStore())
const { flags } = useFeatureFlags()
const supportsNodeFailurePolicy = computed(
() => flags.supportsNodeFailurePolicy
)
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>

View File

@@ -160,6 +160,8 @@ type TestPreviewOutput = {
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
isPartialSuccess?: boolean
executionErrorCount?: number
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
@@ -310,6 +312,23 @@ describe('JobAssetsList', () => {
expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url)
})
it('shows a warning badge for completed jobs with node errors', () => {
const job = buildJob({
taskRef: {
...createTaskRef(createPreviewOutput('job-1.png')),
isPartialSuccess: true,
executionErrorCount: 2
}
})
renderJobAssetsList({ jobs: [job] })
expect(screen.getByTestId('partial-success-badge')).toHaveAttribute(
'aria-label',
'queue.jobDetails.completedWithErrors'
)
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()

View File

@@ -22,7 +22,7 @@
>
<div
:data-job-id="row.job.id"
class="h-12"
class="relative h-12"
@mouseenter="onJobEnter(row.job, $event)"
@mouseleave="onJobLeave(row.job.id)"
>
@@ -84,6 +84,18 @@
</Button>
</template>
</AssetsListItem>
<span
v-if="row.job.taskRef?.isPartialSuccess"
data-testid="partial-success-badge"
class="pointer-events-none absolute bottom-1 left-7 z-2 flex size-4 items-center justify-center rounded-full bg-interface-panel-surface"
:aria-label="getPartialSuccessLabel(row.job)"
:title="getPartialSuccessLabel(row.job)"
>
<i
aria-hidden="true"
class="icon-[lucide--triangle-alert] size-3 text-warning-background"
/>
</span>
</div>
</div>
</template>
@@ -394,6 +406,11 @@ function getJobIconClass(job: JobListItem): string | undefined {
return undefined
}
function getPartialSuccessLabel(job: JobListItem): string {
const count = job.taskRef?.executionErrorCount ?? 0
return t('queue.jobDetails.completedWithErrors', { count }, count)
}
watch(
() => displayedJobGroups,
() => {

View File

@@ -83,12 +83,54 @@
{{ errorMessageValue }}
</div>
</div>
<div
v-if="taskForJob?.isPartialSuccess"
class="rounded-sm border border-warning-background/30 bg-warning-background/10 p-3 text-xs/normal text-text-primary"
>
<div class="flex items-center gap-2 font-medium">
<i
aria-hidden="true"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
{{
t(
'queue.jobDetails.completedWithErrors',
{ count: taskForJob.executionErrorCount },
taskForJob.executionErrorCount
)
}}
</div>
<div
v-if="recoverableExecutionErrors.length"
class="mt-3 flex max-h-64 flex-col gap-2 overflow-y-auto"
>
<div
v-for="(error, index) in recoverableExecutionErrors"
:key="`${error.node_id}-${index}`"
class="rounded-sm bg-interface-panel-hover-surface px-3 py-2"
>
<div class="font-medium text-text-primary">
{{
t('queue.jobDetails.nodeError', {
nodeType: error.node_type,
nodeId: error.node_id
})
}}
</div>
<div
class="mt-1 wrap-break-word whitespace-pre-wrap text-text-secondary"
>
{{ error.exception_message }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -96,6 +138,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { getJobDetail } from '@/services/jobOutputCache'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -151,6 +194,33 @@ const jobState = computed(() => {
return jobStateFromTask(task, isInitializing)
})
type RecoverableExecutionError = {
node_id: string | number
node_type: string
exception_message: string
}
const storedExecutionErrors = ref<RecoverableExecutionError[]>([])
const recoverableExecutionErrors = computed<RecoverableExecutionError[]>(() =>
storedExecutionErrors.value.length
? storedExecutionErrors.value
: (executionStore.executionErrorsByJob[props.jobId] ?? [])
)
watch(
() => [props.jobId, taskForJob.value?.isPartialSuccess] as const,
async ([jobId, isPartialSuccess]) => {
storedExecutionErrors.value = []
if (!isPartialSuccess) return
const detail = await getJobDetail(jobId)
if (props.jobId === jobId) {
storedExecutionErrors.value = detail?.execution_errors ?? []
}
},
{ immediate: true }
)
const firstSeenTs = computed<number | undefined>(() => {
const task = taskForJob.value
return task?.createTime

View File

@@ -55,6 +55,23 @@ describe('useFeatureFlags', () => {
)
})
it('should access supportsNodeFailurePolicy', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsNodeFailurePolicy).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY,
false
)
})
it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {

View File

@@ -16,6 +16,7 @@ import { getDevOverride } from '@/utils/devFeatureFlagOverride'
*/
export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
SUPPORTS_NODE_FAILURE_POLICY = 'supports_node_failure_policy',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
@@ -77,6 +78,12 @@ export function useFeatureFlags() {
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
},
get supportsNodeFailurePolicy() {
return api.getServerFeature(
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY,
false
)
},
get maxUploadSize() {
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
},

View File

@@ -1092,6 +1092,7 @@
"runWorkflowFront": "Run workflow (Queue at front)",
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
"run": "Run",
"continueIndependentBranches": "Continue independent branches if a node fails",
"stopRunInstant": "Stop Run (Instant)",
"stopRunInstantTooltip": "Stop running",
"execute": "Execute",
@@ -1366,6 +1367,8 @@
"failedAfter": "Failed after",
"errorMessage": "Error message",
"report": "Report",
"completedWithErrors": "Completed with {count} node error | Completed with {count} node errors",
"nodeError": "{nodeType} (node {nodeId})",
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",
"eta": {
"seconds": "~{count} second | ~{count} seconds",

View File

@@ -68,6 +68,31 @@ describe('fetchJobs', () => {
expect(result[1].id).toBe('job2')
})
it('preserves partial success metadata on completed jobs', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([
createMockJob('job1', 'completed', {
completion_status: 'partial_success',
has_errors: true,
execution_error_count: 2
})
])
)
})
const [job] = await fetchHistory(mockFetch)
expect(job).toMatchObject({
status: 'completed',
completion_status: 'partial_success',
has_errors: true,
execution_error_count: 2
})
})
it('assigns synthetic priorities', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,

View File

@@ -64,6 +64,9 @@ const zRawJobListItem = z
preview_output: zPreviewOutput.nullable().optional(),
outputs_count: z.number().nullable().optional(),
execution_error: zExecutionError.nullable().optional(),
completion_status: z.enum(['success', 'partial_success']).optional(),
has_errors: z.boolean().optional(),
execution_error_count: z.number().int().optional(),
workflow_id: z.string().nullable().optional(),
priority: z.number().optional()
})
@@ -79,7 +82,8 @@ export const zJobDetail = zRawJobListItem
outputs: zTaskOutput.optional(),
update_time: z.number().optional(),
execution_status: z.unknown().optional(),
execution_meta: z.unknown().optional()
execution_meta: z.unknown().optional(),
execution_errors: z.array(zExecutionError).optional()
})
.passthrough()

View File

@@ -16,6 +16,9 @@ import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const mockUseColorPaletteStore = vi.hoisted(() => vi.fn())
const mockExecutionState = vi.hoisted(() => ({
nodes: {} as Record<string, { state: string }>
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: mockUseColorPaletteStore
}))
@@ -25,9 +28,9 @@ vi.mock('@/utils/colorUtil', () => ({
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
useExecutionStore: vi.fn(() => ({
nodeProgressStates: mockExecutionState.nodes
}))
}))
describe('minimapCanvasRenderer', () => {
@@ -37,6 +40,7 @@ describe('minimapCanvasRenderer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExecutionState.nodes = {}
mockContext = {
clearRect: vi.fn(),
@@ -221,6 +225,31 @@ describe('minimapCanvasRenderer', () => {
expect(mockContext.strokeRect).toHaveBeenCalled()
})
it('should render blocked execution state with amber outline', () => {
mockGraph._nodes = [mockGraph._nodes[0]]
mockExecutionState.nodes = {
'1': { state: 'blocked' }
}
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
expect(mockContext.strokeStyle).toBe('#F59E0B')
expect(mockContext.strokeRect).toHaveBeenCalled()
})
it('should render groups when enabled', () => {
mockGraph._groups = [
{

View File

@@ -28,6 +28,7 @@ function getMinimapColors() {
errorColor: '#FF0000',
runningColor: '#00FF00',
successColor: '#239B23',
blockedColor: '#F59E0B',
isLightTheme
}
}
@@ -116,7 +117,13 @@ function renderNodes(
w: number
h: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
executionState?:
| 'pending'
| 'running'
| 'finished'
| 'error'
| 'blocked'
| null
}>
>()
@@ -162,6 +169,9 @@ function renderNodes(
} else if (node.executionState === 'finished') {
ctx.strokeStyle = colors.successColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (node.executionState === 'blocked') {
ctx.strokeStyle = colors.blockedColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (
node.executionState === 'error' &&
context.settings.renderError

View File

@@ -80,7 +80,13 @@ export interface MinimapNodeData {
bgcolor?: string
mode?: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
executionState?:
| 'pending'
| 'running'
| 'finished'
| 'error'
| 'blocked'
| null
}
/**

View File

@@ -18,6 +18,7 @@ import { app } from '@/scripts/app'
const mockData = vi.hoisted(() => ({
mockExecuting: false,
mockExecutionState: 'idle' as 'idle' | 'error' | 'blocked',
mockLgraphNode: null as Record<string, unknown> | null
}))
@@ -91,7 +92,7 @@ vi.mock(
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined),
executionState: computed(() => 'idle' as const)
executionState: computed(() => mockData.mockExecutionState)
}))
})
)
@@ -178,6 +179,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockExecutionState = 'idle'
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -247,6 +249,34 @@ describe('LGraphNode', () => {
expect(overlay).toHaveClass('border-node-stroke-executing')
})
it('should render failed execution state separately from validation errors', () => {
mockData.mockExecutionState = 'error'
const { container } = renderLGraphNode({ nodeData: mockNodeData })
expect(getNodeRoot(container)).toHaveAttribute(
'data-execution-state',
'error'
)
expect(screen.getByTestId('node-inner-wrapper')).toHaveClass(
'ring-destructive-background'
)
})
it('should render blocked execution state as a warning', () => {
mockData.mockExecutionState = 'blocked'
const { container } = renderLGraphNode({ nodeData: mockNodeData })
expect(getNodeRoot(container)).toHaveAttribute(
'data-execution-state',
'blocked'
)
expect(screen.getByTestId('node-inner-wrapper')).toHaveClass(
'ring-warning-background/70'
)
})
it('should initialize height CSS vars for collapsed nodes', () => {
const { container } = renderLGraphNode({
nodeData: {

View File

@@ -9,6 +9,9 @@
:data-node-id="nodeData.id"
:data-collapsed="isCollapsed || undefined"
:data-ghost="nodeData.flags?.ghost || undefined"
:data-execution-state="
executionState === 'idle' ? undefined : executionState
"
:class="
cn(
'group/node lg-node absolute isolate text-xs',
@@ -75,7 +78,9 @@
'w-(--node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
(hasAnyError || executionState === 'error') &&
'ring-4 ring-destructive-background',
executionState === 'blocked' && 'ring-4 ring-warning-background/70',
bypassed && bypassOverlayClass,
muted && mutedOverlayClass,
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
@@ -352,7 +357,8 @@ const isSelected = computed(() => {
const nodeLocatorId = computed(
() => getLocatorIdFromNodeData(nodeData) ?? undefined
)
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const { executing, progress, executionState } =
useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()

View File

@@ -64,7 +64,7 @@ const zProgressWsMessage = z.object({
const zNodeProgressState = z.object({
value: z.number(),
max: z.number(),
state: z.enum(['pending', 'running', 'finished', 'error']),
state: z.enum(['pending', 'running', 'finished', 'error', 'blocked']),
node_id: zNodeId,
prompt_id: zJobId,
display_node_id: zNodeId.optional(),
@@ -94,7 +94,15 @@ const zExecutionWsMessageBase = z.object({
})
const zExecutionStartWsMessage = zExecutionWsMessageBase
const zExecutionSuccessWsMessage = zExecutionWsMessageBase
const zExecutionSuccessWsMessage = zExecutionWsMessageBase.extend({
completion_status: z.enum(['success', 'partial_success']).optional(),
has_errors: z.boolean().optional(),
execution_error_count: z.number().int().optional(),
failed_node_ids: z.array(zNodeId).optional(),
blocked_node_ids: z.array(zNodeId).optional(),
blocked_output_node_ids: z.array(zNodeId).optional(),
successful_output_node_ids: z.array(zNodeId).optional()
})
const zExecutionCachedWsMessage = zExecutionWsMessageBase.extend({
nodes: z.array(zNodeId)
})
@@ -181,6 +189,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
typeof zExecutionInterruptedWsMessage
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type ExecutionNodeErrorWsMessage = ExecutionErrorWsMessage
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type NodeProgressState = z.infer<typeof zNodeProgressState>

View File

@@ -37,6 +37,7 @@ import type {
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionNodeErrorWsMessage,
ExecutionInterruptedWsMessage,
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
@@ -75,6 +76,7 @@ interface QueuePromptRequestBody {
client_id: string
prompt: ComfyApiWorkflow
partial_execution_targets?: NodeExecutionId[]
node_failure_policy?: 'continue_independent'
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON
@@ -135,6 +137,7 @@ interface QueuePromptOptions {
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
*/
partialExecutionTargets?: NodeExecutionId[]
nodeFailurePolicy?: 'continue_independent'
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting and is not sent to backend.
@@ -165,6 +168,7 @@ interface BackendApiCalls {
execution_start: ExecutionStartWsMessage
execution_success: ExecutionSuccessWsMessage
execution_error: ExecutionErrorWsMessage
execution_node_error: ExecutionNodeErrorWsMessage
execution_interrupted: ExecutionInterruptedWsMessage
execution_cached: ExecutionCachedWsMessage
logs: LogsWsMessage
@@ -831,6 +835,7 @@ export class ComfyApi extends EventTarget {
break
case 'execution_start':
case 'execution_error':
case 'execution_node_error':
case 'execution_interrupted':
case 'execution_cached':
case 'execution_success':
@@ -960,6 +965,10 @@ export class ComfyApi extends EventTarget {
...(options?.partialExecutionTargets && {
partial_execution_targets: options.partialExecutionTargets
}),
...(options?.nodeFailurePolicy &&
this.serverSupportsFeature('supports_node_failure_policy') && {
node_failure_policy: options.nodeFailurePolicy
}),
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,

View File

@@ -24,6 +24,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import {
@@ -229,9 +230,19 @@ describe('ComfyApp', () => {
node_errors: nodeErrors,
error: ''
})
api.serverFeatureFlags.value = { supports_node_failure_policy: true }
useQueueSettingsStore().continueIndependentBranches = true
await expect(app.queuePrompt(0)).resolves.toBe(false)
expect(api.queuePrompt).toHaveBeenCalledWith(
0,
expect.any(Object),
expect.objectContaining({
nodeFailurePolicy: 'continue_independent'
})
)
const errorStore = useExecutionErrorStore()
const executionStore = useExecutionStore()
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)

View File

@@ -5,6 +5,7 @@ import { reactive, unref } from 'vue'
import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
@@ -72,6 +73,7 @@ import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExtensionStore } from '@/stores/extensionStore'
@@ -1669,6 +1671,13 @@ export class ComfyApp {
api.apiKey = comfyOrgApiKey ?? undefined
const res = await api.queuePrompt(number, p, {
partialExecutionTargets: queueNodeIds,
nodeFailurePolicy:
useQueueSettingsStore().continueIndependentBranches &&
api.serverSupportsFeature(
ServerFeatureFlag.SUPPORTS_NODE_FAILURE_POLICY
)
? 'continue_independent'
: undefined,
previewMethod
})
delete api.authToken

View File

@@ -1461,6 +1461,26 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.queuedJobs['job-1']).toBeUndefined()
})
it('keeps partial failure states visible until the next execution starts', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
fire('execution_success', {
prompt_id: 'job-1',
timestamp: 1,
completion_status: 'partial_success',
failed_node_ids: ['failed'],
blocked_node_ids: ['blocked']
})
expect(store.activeJobId).toBeNull()
expect(store.nodeProgressStates['failed'].state).toBe('error')
expect(store.nodeProgressStates['blocked'].state).toBe('blocked')
fire('execution_start', { prompt_id: 'job-2', timestamp: 2 })
expect(store.nodeProgressStates).toEqual({})
})
it('does not track success for jobs this client did not queue', () => {
fire('execution_success', { prompt_id: 'foreign-job', timestamp: 0 })
@@ -1613,6 +1633,28 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
describe('execution_error', () => {
it('collects execution_node_error without terminating the job', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
const detail = {
prompt_id: 'job-1',
timestamp: 1,
node_id: 'n1',
node_type: 'SeeDanceVideo',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'Content filtered',
traceback: [],
current_inputs: {},
current_outputs: {}
}
fire('execution_node_error', detail)
expect(store.executionErrorsByJob['job-1']).toEqual([detail])
expect(store.activeJobId).toBe('job-1')
expect(useExecutionErrorStore().lastExecutionError).toBeNull()
})
it('routes a service-level error (no node_id) to the prompt error store', () => {
const errorStore = useExecutionErrorStore()

View File

@@ -17,6 +17,7 @@ import type {
ExecutedWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionNodeErrorWsMessage,
ExecutionInterruptedWsMessage,
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
@@ -119,6 +120,9 @@ export const useExecutionStore = defineStore('execution', () => {
const nodeProgressStatesByJob = ref<
Record<JobId, Record<string, NodeProgressState>>
>({})
const executionErrorsByJob = ref<
Record<JobId, ExecutionNodeErrorWsMessage[]>
>({})
/**
* Map of job ID to workflow ID for quick lookup across the app.
@@ -349,6 +353,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('progress_state', handleProgressState)
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
api.addEventListener('execution_node_error', handleExecutionNodeError)
api.addEventListener('progress_text', handleProgressText)
}
@@ -364,6 +369,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('progress_state', handleProgressState)
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('execution_node_error', handleExecutionNodeError)
api.removeEventListener('progress_text', handleProgressText)
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
@@ -373,6 +379,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
executionErrorStore.clearExecutionStartErrors()
activeJobId.value = e.detail.prompt_id
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
@@ -413,6 +420,36 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
const jobId = e.detail.prompt_id
const partialProgressStates: Record<string, NodeProgressState> = {}
if (e.detail.completion_status === 'partial_success') {
for (const [nodeId, state] of Object.entries(nodeProgressStates.value)) {
if (state.state === 'error' || state.state === 'blocked') {
partialProgressStates[nodeId] = state
}
}
for (const nodeId of e.detail.failed_node_ids ?? []) {
partialProgressStates[nodeId] ??= {
state: 'error',
value: 1,
max: 1,
node_id: nodeId,
display_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId
}
}
for (const nodeId of e.detail.blocked_node_ids ?? []) {
partialProgressStates[nodeId] ??= {
state: 'blocked',
value: 1,
max: 1,
node_id: nodeId,
display_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId
}
}
}
setWorkflowStatus(jobId, 'completed')
const queuedJob = queuedJobs.value[jobId]
const telemetry = useTelemetry()
@@ -430,6 +467,9 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
resetExecutionState(jobId)
if (Object.keys(partialProgressStates).length > 0) {
nodeProgressStates.value = partialProgressStates
}
}
function handleExecuting(e: CustomEvent<string | number | null>): void {
@@ -559,6 +599,23 @@ export const useExecutionStore = defineStore('execution', () => {
resetExecutionState(e.detail.prompt_id)
}
function handleExecutionNodeError(
e: CustomEvent<ExecutionNodeErrorWsMessage>
) {
const jobId = e.detail.prompt_id
const next = { ...executionErrorsByJob.value }
delete next[jobId]
next[jobId] = [
...(executionErrorsByJob.value[jobId] ?? []),
e.detail
].slice(-100)
const jobIds = Object.keys(next)
for (const oldJobId of jobIds.slice(0, -MAX_PROGRESS_JOBS)) {
delete next[oldJobId]
}
executionErrorsByJob.value = next
}
function handleAccountPreconditionError(
detail: ExecutionErrorWsMessage
): boolean {
@@ -835,6 +892,7 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStates,
nodeLocationProgressStates,
nodeProgressStatesByJob,
executionErrorsByJob,
runningJobIds,
runningWorkflowCount,
initializingJobIds,

View File

@@ -677,6 +677,32 @@ describe('useQueueStore', () => {
expect(updatedTask).not.toBe(initialTask)
})
it('should recreate TaskItemImpl when completion metadata changes', async () => {
const job = createHistoryJob(10, 'job-1')
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
mockGetHistory.mockResolvedValue([job])
await store.update()
const initialTask = store.historyTasks[0]
expect(initialTask.isPartialSuccess).toBe(false)
mockGetHistory.mockResolvedValue([
{
...job,
completion_status: 'partial_success',
has_errors: true,
execution_error_count: 2
}
])
await store.update()
expect(store.historyTasks[0]).not.toBe(initialTask)
expect(store.historyTasks[0].isPartialSuccess).toBe(true)
expect(store.historyTasks[0].executionErrorCount).toBe(2)
})
it('should reuse TaskItemImpl when outputs_count unchanged', async () => {
const job = {
...createHistoryJob(10, 'job-1'),

View File

@@ -342,6 +342,14 @@ export class TaskItemImpl {
return this.job.execution_error ?? undefined
}
get executionErrorCount(): number {
return this.job.execution_error_count ?? 0
}
get isPartialSuccess(): boolean {
return this.job.completion_status === 'partial_success'
}
get workflowId(): string | undefined {
return this.job.workflow_id ?? undefined
}
@@ -571,7 +579,6 @@ export const useQueueStore = defineStore('queue', () => {
.slice(0, toValue(maxHistoryItems))
// Reuse existing TaskItemImpl instances or create new
// Must recreate if outputs_count changed (e.g., API started returning it)
const existingByJobId = new Map(
currentHistory.map((impl) => [impl.jobId, impl])
)
@@ -579,8 +586,12 @@ export const useQueueStore = defineStore('queue', () => {
const nextHistoryTasks = sortedHistory.map((job) => {
const existing = existingByJobId.get(job.id)
if (!existing) return new TaskItemImpl(job)
// Recreate if outputs_count changed to ensure lazy loading works
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
if (
existing.outputsCount !== (job.outputs_count ?? undefined) ||
existing.job.completion_status !== job.completion_status ||
existing.job.has_errors !== job.has_errors ||
existing.job.execution_error_count !== job.execution_error_count
) {
return new TaskItemImpl(job)
}
return existing
@@ -675,7 +686,8 @@ export const isInstantRunningMode = (
export const useQueueSettingsStore = defineStore('queueSettingsStore', {
state: () => ({
mode: 'disabled' as AutoQueueMode,
batchCount: 1
batchCount: 1,
continueIndependentBranches: false
})
})

View File

@@ -10,6 +10,7 @@
// the real composable's auth/remote-config dependency graph into the bundle.
export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
SUPPORTS_NODE_FAILURE_POLICY = 'supports_node_failure_policy',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',