diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index fd31448ea4f..55123b2bc79 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -1209,8 +1209,12 @@ def _prepare_inputs(self, node: BaseInvocation): # Inputs must be deep-copied, else if a node mutates the object, other nodes that get the same input # will see the mutation. if isinstance(node, CollectInvocation): - item_edges = [e for e in input_edges if e.destination.field == ITEM_FIELD] - item_edges.sort(key=lambda e: (self._get_iteration_path(e.source.node_id), e.source.node_id)) + # Enumerate to preserve insertion order as a tiebreaker when iteration paths are equal. + # Using source_node_id as a tiebreaker would produce random ordering for non-iterated + # direct connections (e.g. multiple reference images) because node IDs are random UUIDs. + item_edges_indexed = list(enumerate(e for e in input_edges if e.destination.field == ITEM_FIELD)) + item_edges_indexed.sort(key=lambda t: (self._get_iteration_path(t[1].source.node_id), t[0])) + item_edges = [e for _, e in item_edges_indexed] output_collection = [copydeep(getattr(self.results[e.source.node_id], e.source.field)) for e in item_edges] node.collection = output_collection diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py index e0b8fd4717d..4506eb3db60 100644 --- a/tests/test_graph_execution_state.py +++ b/tests/test_graph_execution_state.py @@ -300,6 +300,33 @@ def test_graph_validate_self_collector_without_item_inputs_raises_invalid_edge_e graph.validate_self() +def test_collect_preserves_insertion_order(): + """Items directly connected to a CollectInvocation (no iteration) must be collected in insertion order. + + This guards against the bug where items were sorted by source node ID (random UUID), producing + non-deterministic ordering for e.g. multiple Flux.2 reference images. + """ + graph = Graph() + prompts = ["first", "second", "third"] + graph.add_node(PromptTestInvocation(id="a", prompt=prompts[0])) + graph.add_node(PromptTestInvocation(id="b", prompt=prompts[1])) + graph.add_node(PromptTestInvocation(id="c", prompt=prompts[2])) + graph.add_node(CollectInvocation(id="collect")) + # Add edges in the intended left-to-right order + graph.add_edge(create_edge("a", "prompt", "collect", "item")) + graph.add_edge(create_edge("b", "prompt", "collect", "item")) + graph.add_edge(create_edge("c", "prompt", "collect", "item")) + + g = GraphExecutionState(graph=graph) + invoke_next(g) # a + invoke_next(g) # b + invoke_next(g) # c + n_collect, result = invoke_next(g) + + assert isinstance(n_collect, CollectInvocation) + assert g.results[n_collect.id].collection == prompts + + def test_are_connection_types_compatible_accepts_subclass_to_base(): """A subclass output should be connectable to a base-class input.