diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 0b4916a2f..6b1f8914e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -491,6 +491,13 @@ export function useWorkflowExecution() { updateActiveBlocks(data.blockId, false) setBlockRunStatus(data.blockId, 'error') + executedBlockIds.add(data.blockId) + accumulatedBlockStates.set(data.blockId, { + output: { error: data.error }, + executed: true, + executionTime: data.durationMs || 0, + }) + accumulatedBlockLogs.push( createBlockLogEntry(data, { success: false, output: {}, error: data.error }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index 0e6948ac4..cdad58544 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -349,7 +349,15 @@ export function PreviewWorkflow({ if (block.type === 'loop' || block.type === 'parallel') { const isSelected = selectedBlockId === blockId const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) - const subflowExecutionStatus = getSubflowExecutionStatus(blockId) + + // Check for direct error on the subflow block itself (e.g., loop resolution errors) + // before falling back to children-derived status + const directExecution = blockExecutionMap.get(blockId) + const subflowExecutionStatus: ExecutionStatus | undefined = + directExecution?.status === 'error' + ? 'error' + : (getSubflowExecutionStatus(blockId) ?? + (directExecution ? (directExecution.status as ExecutionStatus) : undefined)) nodeArray.push({ id: blockId, diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts index 680e890eb..ffc48bc5f 100644 --- a/apps/sim/executor/execution/edge-manager.test.ts +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -2478,6 +2478,9 @@ describe('EdgeManager', () => { expect(readyNodes).toContain(otherBranchId) expect(readyNodes).not.toContain(sentinelStartId) + // sentinel_end should NOT be ready - it's on a fully deactivated path + expect(readyNodes).not.toContain(sentinelEndId) + // afterLoop should NOT be ready - its incoming edge from sentinel_end should be deactivated expect(readyNodes).not.toContain(afterLoopId) @@ -2545,6 +2548,84 @@ describe('EdgeManager', () => { expect(edgeManager.isNodeReady(afterParallelNode)).toBe(true) }) + it('should not queue loop sentinel-end when upstream condition deactivates entire loop branch', () => { + // Regression test for: upstream condition → (if) → ... many blocks ... → sentinel_start → body → sentinel_end + // → (else) → exit_block + // When condition takes "else", the deep cascade deactivation should NOT queue sentinel_end. + // Previously, sentinel_end was flagged as a cascadeTarget (terminal control node) and + // spuriously queued, causing it to attempt loop scope initialization and fail. + + const conditionId = 'condition' + const intermediateId = 'intermediate' + const sentinelStartId = 'sentinel-start' + const loopBodyId = 'loop-body' + const sentinelEndId = 'sentinel-end' + const afterLoopId = 'after-loop' + const exitBlockId = 'exit-block' + + const conditionNode = createMockNode(conditionId, [ + { target: intermediateId, sourceHandle: 'condition-if' }, + { target: exitBlockId, sourceHandle: 'condition-else' }, + ]) + + const intermediateNode = createMockNode( + intermediateId, + [{ target: sentinelStartId }], + [conditionId] + ) + + const sentinelStartNode = createMockNode( + sentinelStartId, + [{ target: loopBodyId }], + [intermediateId] + ) + + const loopBodyNode = createMockNode( + loopBodyId, + [{ target: sentinelEndId }], + [sentinelStartId] + ) + + const sentinelEndNode = createMockNode( + sentinelEndId, + [ + { target: sentinelStartId, sourceHandle: 'loop_continue' }, + { target: afterLoopId, sourceHandle: 'loop_exit' }, + ], + [loopBodyId] + ) + + const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId]) + const exitBlockNode = createMockNode(exitBlockId, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [intermediateId, intermediateNode], + [sentinelStartId, sentinelStartNode], + [loopBodyId, loopBodyNode], + [sentinelEndId, sentinelEndNode], + [afterLoopId, afterLoopNode], + [exitBlockId, exitBlockNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { + selectedOption: 'else', + }) + + // Only exitBlock should be ready + expect(readyNodes).toContain(exitBlockId) + + // Nothing on the deactivated path should be queued + expect(readyNodes).not.toContain(intermediateId) + expect(readyNodes).not.toContain(sentinelStartId) + expect(readyNodes).not.toContain(loopBodyId) + expect(readyNodes).not.toContain(sentinelEndId) + expect(readyNodes).not.toContain(afterLoopId) + }) + it('should still correctly handle normal loop exit (not deactivate when loop runs)', () => { // When a loop actually executes and exits normally, after_loop should become ready const sentinelStartId = 'sentinel-start' diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index 68a936104..d2b8a0595 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -71,7 +71,13 @@ export class EdgeManager { for (const targetId of cascadeTargets) { if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) { - if (this.isTargetReady(targetId)) { + // Only queue cascade terminal control nodes when ALL outgoing edges from the + // current node were deactivated (dead-end scenario). When some edges are + // activated, terminal control nodes on deactivated branches should NOT be + // queued - they will be reached through the normal activated path's completion. + // This prevents loop/parallel sentinels on fully deactivated paths (e.g., an + // upstream condition took a different branch) from being spuriously executed. + if (activatedTargets.length === 0 && this.isTargetReady(targetId)) { readyNodes.push(targetId) } }